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

Weft The book

The weft book

Weft is a language and a runtime for programs that combine AI models, people and tools.

Running an agent puts one model in charge of the whole job, and you wait while it works. Weft goes the other way. You break the job into a graph of small steps, called nodes. A node can run a model, open a webhook, ask a person, call an API or keep a database. Each one sees only what you wired to it, and the graph does the coordinating. The compiler checks every connection before the run, and every value that passes between nodes is written down where you can read it afterwards.

Let Tangle show you

Two things are yours to do, and both are quick. Install weft, which is mostly waiting, and decide which services you want weft to keep keys for. Then make a project:

weft new hello --assistant claude-code --remember

That puts Tangle in the project, and --remember makes Claude Code the default so your next weft new needs no flag. Tangle is our weft specialist: it knows the language, reads the catalog on your disk before it wires anything, writes any step you are missing, and will build you a web frontend if you ask for one. It also tests what it builds. It runs one piece at a time on a real input, looks at what came out, and goes back to fix the step that got it wrong. You can hand it a job and find the program working when you come back.

Open the project in your assistant and ask it to show you around, or just say what you want built. It walks you through everything below, pointing at your own screen while it does. You do not need the rest of this book.

Or read it yourself

If you would rather build with your own hands, or you just prefer reading, two sections get you to the same place.

Set it up gets you to a running program: install, your first project, the three things in a weft project that are alive on their own clocks, and how to talk to Tangle.

Build something real is where you take the wheel: editing the graph with a mouse, running one part of it on its own, connecting an account, giving your machine an address the internet can reach, and putting a person in the middle of a run.

After that the book stops being a path and becomes a reference. The language, writing your own nodes, connections, and what the runtime is doing underneath. Read a section when you need it.

The last part, why it is shaped this way, is the thinking behind the design: what weft handles for you, and how we decide what belongs in the language.

If something here is wrong

These docs are AI-written and the code moves fast, so mistakes get through. If you find one, or an explanation you cannot follow, tell us or come and say so on Discord. You do not need the correction worked out before you raise it.

Install

This gets you a weft runtime on your own machine, the weft command, and the graph editor in VS Code. It is mostly waiting, so keep reading while it runs.

One tool has to be there first: Docker. weft’s runtime is a program on your machine, and everything it starts (your programs, their databases and servers) runs in Docker containers next to it. If Docker is missing, the installer stops and prints the link to install it.

The installer is a bash script, so on Windows run it inside WSL.

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

Put your keys in before you install

Fill in the services you expect to use often, and leave the rest alone. We recommend at least OpenRouter, because a single key there reaches most models.

A service you set up here becomes one click forever after: a step that calls it picks the key up on its own, and your AI assistant can wire one in without stopping to ask you for anything. A service you skip asks you for a credential the first time you need it, which is fine for the ones you touch once.

cp access-apps.example.json access-apps.json

Open it and edit in place, leaving the services you are not using on their placeholder values. weft ignores an entry it cannot use.

  "openrouter": [{
    "kind": "api_key",
    "label": "Runtime key",
    "key": "sk-or-REPLACE_WITH_YOUR_KEY"
  }],

The file also ships entries for Slack, Google, Notion, Airtable, fal, ElevenLabs, Exa, Firecrawl and Mistral. Most of them just need a key pasted in. Slack and Google want you to register an application with that provider first, which is a longer job, so do those only if they are services you will live in. For the whole format, go and read the apps file.

Install

./setup.sh

The installer asks no questions: the first run starts the database and pulls images, then sets the runtime up as a service of your user (systemd on Linux, launchd on macOS), so it comes back on its own after a reboot.

Check it came up

weft daemon status

A healthy answer counts your projects:

weft: running at http://127.0.0.1:14111 (install 'default'); 0 project(s)
public address: closed

closed there is the normal state and not a fault. It means nothing of yours is reachable from the internet, which is what you want until you need a public address.

If it says weft: unreachable at ..., run weft daemon logs to see how the runtime’s log ends (the line also names the log file), then run ./setup.sh again and read the last block it prints. If that block has scrolled away, every run writes the same thing to ~/.local/share/weft/setup-runs.log.

Two things that can go differently.

If weft is not found, the installer warned that its directory is not on your PATH and printed an export PATH=... line. Put that line in your shell’s startup file (~/.bashrc for bash, ~/.zshrc for zsh) and open a new terminal.

If VS Code was closed, or code is not on your PATH, the installer built the editor extension but could not install it, and printed the exact code --install-extension ... command to finish the job. Without the extension everything the weft command does still works; you just will not see the graph.

The installer also needs Rust when it cannot download a prebuilt weft for your machine, and Node 20 with pnpm when it cannot download a prebuilt editor extension. It tells you which, up front, and stops rather than half-installing.

Add the browser extension

When a program stops to ask a person something, the question shows up in a browser extension called Weft tasks. Install it now, from Firefox Add-ons or the Chrome Web Store, and close it again. It has nothing to show you yet. You connect it to your runtime in putting a person in the loop.

Now leave this folder

Your projects do not belong inside the weft checkout. Go wherever you keep code:

cd ~/code

Then build your first program.

When you come back to this page

To change your keys, edit access-apps.json in the checkout; the change takes effect at once (the apps file).

To update, pull and run ./setup.sh again. Your projects and their history stay where they are. Before you do, check the changelog for anything that needs you.

If you installed weft before it stopped running on Kubernetes, that update is the exception: its database cannot come along. ./setup.sh finds the old install and asks before wiping it. The wipe deletes the old cluster, your run history, versions and stored connections, and every container and image weft made, along with every named install on the machine (the question lists them). Your project folders stay, and weft run registers them again. If ./setup.sh has no terminal to ask on, run scripts/scrub-old-install.sh yourself first.

To remove it, run ./setup.sh --uninstall. It stops the runtime and removes the CLI and the editor extension. Your database, stored connections and your programs’ infrastructure stay, so a later ./setup.sh brings everything back. Adding --purge deletes all of that too, your programs’ own databases included.

Your first program

Making a project takes one command, and it has one choice in it: which AI assistant you use. Pass --assistant with your row from this table and weft copies Tangle into the project. Tangle is a persona that teaches your assistant the language, and it reads the catalog on your disk rather than trusting what it remembers.

You usePass
Claude Code--assistant claude-code, or cc
Kilo Code--assistant kilo-code, or kc
Cursor--assistant cursor, or cu
Codex--assistant codex, or cx
GitHub Copilot--assistant github-copilot, or gh
Gemini CLI--assistant gemini-cli, or gc
Cline--assistant cline, or cl
OpenCode--assistant opencode, or oc
Devin Desktop--assistant devin-desktop, or dd
Junie--assistant junie, or ju
Something else--assistant agents, which writes a plain AGENTS.md
Nothing--assistant none

If you use more than one, pass --assistant once for each. The pick is for this project only. If you want your next weft new to install the same one with no flag, add --remember, as here:

weft new hello --assistant claude-code --remember
cd hello
weft run

It prints the project’s id, then the run’s id, then a timestamped line for every step as it starts and finishes, then a tick:

registered hello (3f2a1c4e-9b81-4d0a-8e55-7c2f1a6b30de)
started execution 9b81d0a2-4e17-4a33-bc90-51d8e2f4a7c1 on version a1b2c3d
→ started execution=9b81d0a2 entry=greeting
  …
✓ completed execution=9b81d0a2

That long id after started execution is the run’s execution id, and it is how you point at this one run later. Everything that reads a run takes it, and the first eight characters are enough as long as they name only one.

If weft is not found, open a new terminal; the installer’s PATH line only takes effect in a fresh one. If the run stops saying it cannot reach the runtime, run weft daemon status and go back to Install.

See it as a graph

Open the hello folder in VS Code, click the Weft icon in the bar down the left edge, and click your project under Projects. The source opens on the left and the graph opens beside it. (If you opened src/main.weft yourself, click Open Graph to the Side in the file’s title bar.)

Type in either pane and the other updates.

This is what weft new wrote, and what you just ran:

greeting = Text { value: "Hello, World!" }
out = Debug { data: greeting.value }

Two boxes and an arrow. A box is one step, which weft calls a node. The dots down its left edge are what it takes in, the dots down its right edge are what it sends out, and each dot is coloured by the kind of value it carries, so two dots of the same colour fit together.

The arrow is greeting.value sitting where out’s data input goes. Writing a source and a port where a value belongs is how you connect two steps, and it is the only wiring syntax there is.

While a run is going, each box glows: amber while it works, cyan while it waits for something, green when it finished, red when it failed. Click the magnifying glass on a box to see exactly what went in and what came out.

For adding a box and drawing an arrow yourself, go and read working in the graph.

What weft new made

File or directoryWhat it is
src/main.weftThe program
weft.tomlIts name and its permanent id
nodes/base_catalog/Every step weft ships, copied in so the build never reaches outside your project. weft catalog update replaces this folder wholesale after you update weft, so keep nothing of your own in here
nodes/ (anywhere else)Steps you write yourself
CLAUDE.md, .claude/Tangle, for whichever assistant you picked
.weft/Build state, generated

Two more folders turn up later rather than now: layouts/, the first time you drag a box, and assets/, when something needs a prompt or a script kept in a file of its own.

weft new also runs git init and writes a .gitignore. Tangle’s files are ordinary project files, so they commit with the rest and anyone who clones your project gets Tangle without installing weft.

Next, the three lifecycles: what starts on its own and what does not.

The three lifecycles

Three things in a weft project are alive on their own clocks, and you start each one yourself. None of them starts another.

What it isYou start it with
InfrastructureContainers your program asked for: a database, a model server, anything that has to keep runningweft infra start
TriggersThe listening: a webhook waiting, a timer ticking, a form open for answersweft activate
ExecutionsOne run of the graph, start to finishweft run, or a trigger firing, or a caller arriving

Hitting Run never starts your database and never puts your webhooks live. That is the whole idea and it catches everyone once.

Your hello program has neither of the first two. It is two boxes and no listening, so weft run is the whole story until you build something that talks to the world.

Why they are separate

Infrastructure costs money while it runs, so weft will not start a container because you clicked something that did not mention one. If you run a program whose containers are down, it refuses up front and names them:

infra not running for: pg. Run `weft infra start` first.

Activating reaches out to other people’s systems. A trigger that waits for Slack messages registers a subscription with Slack during activation. A trigger holding a socket open dials it there and then. That is a real thing happening in somebody else’s account, which is why it is a separate decision from running your program once.

It also has a consequence you will meet: activation freezes the trigger’s inputs, because those values went to the provider and the provider is holding them. Edit one afterwards and your live listener carries on with what it registered, until you run weft resync.

An execution is just one run. It ends completed, failed or cancelled, or it parks at waiting for input while a step waits on a person or a service. Parking is free: weft shuts the machinery down and a fresh copy picks the run back up when the answer lands, however many days later.

The rest

For every verb, what each status means, the difference between a run that is waiting and one that is stuck, and which command to run after you edit something, go and read starting and stopping things.

Next, talk to Tangle and have it build you something with all three.

Talking to Tangle

Tangle is the weft expert that weft new --assistant copied into your project. Open the project in your assistant and it is already there. Describe what you want in plain words and it builds it. You do not have to learn this language.

Say what it should do, not how

I want a WhatsApp bot I can forward voice notes to, and it replies with what was said in the audio. If I send it text, it just ignores it.

Tangle turns that into a graph and tells you the shape in a sentence or two before it builds anything. Say so then if the shape is wrong.

Notice what you did not say. Not which model transcribes the audio, not how the bot receives a message, and not how to tell a voice note from a text. That last clause, the one about ignoring text, is a branch in the finished graph.

What it does without being asked

It looks things up rather than remembering. The catalog on your disk is the truth, and Tangle reads a step’s real inputs and outputs before wiring it. The language moves fast, so anything the model remembers is probably stale.

It writes missing steps. A step’s own logic is Rust, and if nothing in the catalog does what you need, Tangle designs the step’s inputs and outputs and hands it to a helper agent that writes that Rust.

It writes the prompts. When a model’s answer decides what happens next, Tangle hands that prompt to a helper that does nothing but write prompts, rather than typing one out as it goes.

It builds you a frontend. Ask for a page, an app or a site and Tangle puts one in front/, talking to your program through the program’s own HTTP routes. The default stack is SvelteKit, PostgreSQL, BetterAuth and shadcn-svelte, and if you name a different one, yours wins.

It tests as it goes. Tangle runs one piece of the program on its own, with values it makes up or a real input you gave it. It reads what came out, fixes the step, then runs only what changed. When a case comes out right, Tangle freezes it, and after a later change it runs that case again and reads what moved. You do not have to be there for any of that.

It offers guard rails on risky programs. If strangers can post into your program, or it sends, deletes or spends money on its own, Tangle names the protections that slow things down (a check, a gate, a person who has to approve) and asks you before it builds. When the program is finished, it attacks it before handing it over.

Give it one real case

Paste in a real input: an actual email, an actual message. Then read what came out, and when it is wrong, say what is wrong with it rather than restating the goal.

This email is asking for a replacement, but the extracted request says the customer wants a refund. Fix the prompt for that step.

Tangle builds one stage, runs it on your case, and shows you the result before writing the next one. For the longer version of that habit, go and read Sequential Diffusion Programming.

When you want to do it yourself

The .weft file is the source, and the graph edits that same file. For every command, go and read the CLI.

That is the first ten minutes. Next, work in the graph with your own hands.

Working in the graph

Everything here works with the mouse, and every gesture rewrites your .weft file. The two panes are one file, so nothing you do on the canvas is stored in a second place that could disagree with your source.

Getting around

Scroll to move around. Drag empty canvas to move around faster. Zoom is the + and - buttons at the bottom left, with Fit View beside them for when you drag something into the distance and lose it. Scrolling does not zoom, on purpose, so a stray trackpad gesture cannot throw your whole graph off screen.

Hold Shift and drag to select several boxes at once.

Add a step

Press Ctrl+P (Cmd+P on a Mac) and search. Right-clicking empty canvas and picking Add Node… opens the same list.

The list holds every step your project knows about, and Group and Loop are in there with the rest, because a group is a kind of step.

It also holds the plain actions, so you can find them without remembering a chord: Undo, Redo, Duplicate Selected, Delete Selected, Select All Nodes, Fit View, and Auto Organize Layout, which tidies the whole canvas.

If you already know what should come next, drag out of a dot and let go over empty canvas. The list opens, and the step you pick arrives with the arrow already drawn. One Ctrl+Z takes away both.

If the text of the file changes outside the graph (you type in the code tab, or an AI writes the file), the graph forgets its undo history, because what it remembered no longer matches the text. Undo those earlier graph edits in the text editor instead. The graph never writes over a change made to the text.

Connect two steps

Drag from a dot on one box’s right edge to a dot on another box’s left edge. The line takes the colour of the dot it started from.

Three things will refuse the drop, and each tells you which:

  • The types do not fit. Same colour is the quick check. The full rules are in Types.
  • The two boxes are in different groups. A value crossing that line has to go through the group’s own dots. That is what a group is for.
  • The input already has a value written into it. You get '<port>' is driven by a config assignment; unset it first to drive it with an edge. Clear the field, then draw the arrow.

To remove an arrow, grab it near one end and drop it on empty canvas.

To take one field out of a value instead of the whole thing, right-click the arrow. You get the keys inside it with their types, and clicking one narrows the arrow to that key. Up one level goes back and Read the whole value undoes it.

Change what a step does

The small arrow in a box’s header opens its body, where its fields are: text boxes, dropdowns, tickboxes, code editors, file pickers. A field fed by an arrow hides itself, because the arrow is now the source of that value.

Under the inputs there is a + input button, and + output under the outputs. A port you add starts with the type MustOverride, which is weft saying nobody has decided this yet, and the build stops until you do. Right-click the new dot and pick the Type: row to set it.

That same right-click menu makes an input required or optional, removes it, or puts a catalog port back the way it was.

Double-click a box’s name to rename it. Right-click a box for Duplicate, Delete, Tags…, Set as target and Run from here….

On an infra node, the same menu has One per instance: each instance of your program then gets its own copy of it, and it shows a per instance badge. Share across instances takes the mark off.

If you want each instance to get its own value for a field (an account, a spreadsheet, a schedule), click the ⧉ button beside the field’s name, or right-click the field and pick Let each instance fill this (the same entry is on the right-click menu of the field’s input port). The field then shows Filled by each instance instead of a control, and the node a filled per instance badge. If you had already written a value, an instance that gets none gets that one. Click ⧉ again, or pick Stop letting each instance fill this, and that value becomes the one value for everyone again, or the field goes back to empty if there was none. A field with a wire into it offers neither, because its value comes from the wire.

A box that reads from a per-instance node or an instance-filled field runs once for each instance too, and shows a dashed per instance (follows) badge. For what all of this means, go and read programs with instances.

Run one branch instead of everything

Right-click the box whose answer you want and choose Set as target. It gets a breathing ring, and the Run button becomes Run 1 target. Only that box and the boxes feeding it will run. Set as many as you like; right-clicking a target again offers Unset target.

The same thing from a terminal:

weft run --target daily_report

The walk stops when it reaches a trigger, and a box that two branches share does not drag the other branch in. A target inside a group brings only the work needed through it. A loop is the exception and comes along whole.

For anything more specific, the chevron glued to the Run button opens Run from a spec…, a dialog where you can start partway in with values you supply, exclude an endpoint, run one group on its own, fire a single trigger with a made-up event, or reuse what did not change since the last run. Right-clicking a box and choosing Run from here… opens the same dialog already filled in. Everything in there has a flag on the CLI, and the rules are in versions, seeds and frozen examples.

Watch it go

Run Project is at the bottom of the canvas, or press Ctrl+Enter with the graph focused. While a run is going, that button becomes Stop Execution. The bar follows what happens whoever started it: an infra start typed in a terminal, or asked for by Tangle, shows as Starting infra… on the infrastructure buttons, and an activation as Activating…, each with its Cancel, the same as clicking them.

Each box gets a glow and a small mark:

The boxWhat happened
Amber glow, ●Running now
Cyan glow, ◉Waiting for an answer from outside
Green glow, ✓Finished
Red glow, ✕Failed
Grey ■You stopped it
Grey ⊘Skipped
Purple glowReused from an earlier run instead of run again
Dimmed, no glowOutside the part of the graph this run covered

Click the magnifying glass in a box’s header, Inspect execution, for what went in, what came out, how long it took and what it cost. A failed box shows its error there. A skipped box says why in words, such as its _should_flow said no or the required input 'x' closed, and you follow the arrows backwards from there to find the step that decided. If a step changed a stored file in place (added a turn to a conversation, say), its panel lists each change under Files edited: the file, the versions it moved between, and the lines added and removed.

A box that ran several times, which happens inside a loop, gets a ‹ 2/5 › in its header to step through the firings.

Look at an older run

The toggle at the top left decides which run the graph shows. Hover any of its three parts to see what it does:

  • Following puts every run that starts on the graph. It is the default, and clicking Run in the editor always comes back to it.
  • Locked keeps the graph on the run it shows, so you can read it while others start.
  • Off takes the run away and leaves just the program.

While locked or off, runs that start are counted in a button beside the toggle, like 2 new runs · Follow, and clicking it shows the newest and follows again. A run started from a terminal, yours or an assistant’s, never changes the mode. Each project remembers whether you left it following.

For a run from yesterday, click the Weft icon in the side strip. Executions lists every run of the project you last opened, newest first, and each row has View in Graph, which puts that run back on the canvas with its values in place and locks the toggle onto it. A replayed run looks exactly like a live one.

If your project deploys to a cloud install, the switch at the top right shows the graph as that install runs it. For how it works, go and read Look at prod from the editor.

Fold a big program down

Every group has Collapse group in its header, and Expand group when it is shut. The wiring outside does not change either way.

To make a group, add a Group step and drag boxes into it. weft decides where a box landed from its centre, so drag it until its middle is inside. To undo that, delete the group: every box inside climbs out one level and keeps its position.

Moving a box in or out of a group changes your program, because it changes what that box can reach. Dragging a box anywhere else does not: positions live in layouts/, beside your source, not in it.

To show your program to somebody, there is a Simplified view switch at the top right. Every box becomes a plain square with one dot on each side. You can still move boxes and fold groups, and you cannot wire anything, which is the point.

Next, connect an account.

Connecting an account

A step that sends a Slack message needs a Slack account. You connect it once, weft keeps the credential, and your program passes around a reference to it rather than the secret itself.

Do it in the graph

A step that talks to an outside service has a blue Connect Slack… button in its body, named after whatever service it needs. Until you pick something, that step will not fold shut: where the fold arrow normally sits it says Pick a connection first.

Click it and you get your existing connections for that service, then, under + Add a connection, the ways to make a new one:

  • Use App name (one click), one row per application your runtime was configured with, each showing the permissions it asks for. Those permissions are fixed per application, so you pick an application rather than ticking boxes.
  • Use ours (uses your credits), when the runtime holds a key for that service. This is the row that appears because you filled in access-apps.json before installing.
  • Your own…, always. Tick the permissions you want, follow the guide for registering an application with that provider, or just paste a key you already have.

Sign-in opens your browser for the provider’s consent screen and the panel waits, then flips to a green Connected as your name with Change and Disconnect beside it.

If you take the shared door, weft warns you once that somebody else in the same workspace using that same application may disconnect you, and that your own application avoids it.

Or from the terminal

weft connect

Run it in the project folder, pick the step, and answer the prompts. weft connect --list prints what is already stored and changes nothing. weft connect --node account goes straight to one step. --disconnect clears a step’s pick and leaves the stored account alone, and --forget <id> deletes the stored account for good.

Picked on each install

Which connection a step uses is kept by the install you picked it on, never written in your .weft files. A connection lives in one install, so its id means nothing on another: your machine and your cloud each have their own accounts, and each keeps its own pick. If you want a step connected on your cloud install, run weft connect --on prod there (it is the same walkthrough). A step inside a file you include twice is two steps, one per place, and each place gets its own pick.

If you run a program with a step nobody connected on this install, the run is refused before it starts, naming the step and the weft connect that fixes it. weft activate and weft resync check every step of the program the same way before any trigger moves, so a missing pick is caught there and not at the first call. Forgetting a stored connection takes every pick of it with it.

A pick is kept under the step’s full address, and the file a step is written in is part of that address. So if you move chat.weft into a studio/ folder, the step that was chat.send.agent is now studio.chat.send.agent, and its pick stays behind under the old name. weft never guesses where a step went. When a step of the same kind now has no pick, activating refuses and names both addresses:

'chat.send.agent' has connections or values stored on this install and is no
longer in the program; did you move it to 'studio.chat.send.agent'?
`weft connect --move chat.send.agent studio.chat.send.agent` carries them across

weft connect --move <old> <new> carries the program’s pick and every instance’s values for that step across in one go. It refuses if the new step already has some, or if it takes different connections, since then it is not the same kind of step.

If a build refuses a line like account: {"id": "...", "identity": "..."} with “that was the old way of connecting a node”, the connection was written into the source by an older weft. Erase that line and connect the step again; the install keeps the pick from then on.

Never paste a key into a step’s field

Values written into a step travel through the journal and render in the inspector as plain text, where anybody who can open the project reads them. That is also true of anything you paste into a chat with your assistant.

A connection is the other thing: the secret goes into weft’s own store, and what moves along the wire is a small reference with no secret in it.

{
  "__weft_access__": {
    "accessId": "connection-id",
    "service": "slack",
    "identity": "Acme Corp",
    "requiresPermissions": ["chat:write"],
    "requiresValues": []
  }
}

Every step that consumes it stamps on what it needs, and a connection that falls short is refused when the step opens it rather than failing halfway through a call.

What weft can tell you before a call fails

Providers differ in how honest they are. Some state exactly what they granted, and weft blocks a step whose permission is missing. Others only confirm the credential is alive, or say nothing at all until a real call comes back refused, and then weft lets the call through because it has nothing to block on.

So a bad credential sometimes shows up for the first time when a step tries to use it. For the whole picture, go and read how connections work.

Next, put a person in the loop.

A public address

If you want Slack, Stripe or Telegram to push events into a program running on your laptop, they need somewhere to push them. Your laptop is behind a router and has no address they can reach, so weft opens one for you:

./setup.sh --public-url

That starts a tunnel from your machine to a separate port (14112) where weft serves a short list of paths and nothing else. The choice sticks, so later installs keep it open until you say otherwise.

What it exposes, and what it does not

These paths, and nothing else. Everything else on that address answers 404.

PathWhat arrives there
POST /events/<service>/<topic>A provider pushing you an event. A GET there answers 404
POST /signal/<token>Somebody answering a waiting step through its link
DELETE /signal/<token>Somebody cancelling that waiting step through the same link
POST /signal/<token>/skipSomebody skipping that waiting step
GET /signal-token/signalsA client holding a signal token, listing the steps waiting on it
DELETE /signal-token/signalsThat client clearing every step it listed
GET /signal-token/signals/<signal>/files/<field>That client asking for a fresh link to a file a listed form shows
GET /signal-token/healthThat client checking its token still works
GET /signal-token/displaysThat client listing what your nodes are showing (a QR code, say)
GET /signal-token/displays/<project>/<node>That client reading one node’s display
POST /signal-token/displays/<project>/<node>/actionThat client pressing a button on it
/instance/...Somebody holding an instance token for one of your programs: that instance’s fields, its connections, its runs
GET /public/files/<token>Somebody fetching a file link your program handed out
/connect/<tenant>/<path>A caller of one of your routes or sockets
/infra/<project>/<copy>/...A caller of an infrastructure endpoint your node opened to the public
GET /access/oauth/callbackA provider sending you back after you approve a connection
/access/picker/<state>The page where you pick files for a connection, and its answer
POST /<path>A trigger of yours that fires on a plain POST to its own path
/, /index.html, /logo.pngA small page saying this address is a weft install

Every /signal-token/... request carries the signal token in its Authorization: Bearer header, never in the path, so it stays out of proxy logs.

Your dispatcher’s real API, your projects, your journal and your runs are not on that port at all, so nothing arriving through the tunnel can reach them. They stay on 127.0.0.1:14111, which has no authentication of its own and listens on your machine only.

Find your address

weft daemon status
weft: running at http://127.0.0.1:14111 (install 'default'); 1 project(s)
public address: https://something.example (./setup.sh --no-public-url closes it)

The installer prints it too, when it is open.

Hand a provider the full path for what it is sending, not the bare address: https://something.example/events/slack/messages. Each trigger’s connect panel shows you the exact one it wants.

It changes when the tunnel restarts

This is the thing that bites. The address is not yours to keep. When the tunnel comes back up with a different one, anything you registered against the old one is pointing at nothing, and events stop arriving without an error anywhere: the provider is posting into the void.

So after a restart, check weft daemon status and re-register the address with any provider you configured by hand. A Slack app’s Request URL and a webhook you pasted into somebody’s dashboard both need it.

A stable address of your own

If you want an address that never changes, run the tunnel under a hostname you own. You need a Cloudflare account with that domain on it.

  1. In the Cloudflare dashboard, create a tunnel (Zero Trust, then Networks, then Tunnels) and copy its token.

  2. Add a public hostname to that tunnel, say weft.example.com, and set its service to the port weft serves those paths on: on Linux, http://127.0.0.1:14112, because the tunnel shares your machine’s network; on macOS, http://host.docker.internal:14112.

  3. Install with both set:

    WEFT_PUBLIC_TUNNEL_TOKEN=<token> WEFT_PUBLIC_TUNNEL_HOSTNAME=https://weft.example.com ./setup.sh --public-url
    

    The hostname is the bare https:// address, with no port and no path.

Once the tunnel is up, weft daemon start fetches the page at your address through it. If the service in step 2 is wrong, the start fails and says so, naming the value it expects.

Close it

./setup.sh --no-public-url

The tunnel comes down and the address stops answering.

When you do not need it

Triggers that go and fetch rather than wait to be pushed work with no public address at all: a timer, a poller, anything weft opens an outgoing connection for. So do runs you start yourself, and so does a person answering through the browser extension, because that talks to your runtime directly.

You need this page when a provider insists on pushing. For which triggers those are and how a service declares its event transport, go and read events from a service.

Putting a person in the loop

A step can stop and ask somebody a question. The run parks, the worker shuts down, and when the answer arrives a fresh worker picks the run back up exactly where it was. Waiting is free, so a program can sit on a question for a week without costing you anything.

This is HumanQuery, and it looks like this:

review = HumanQuery {
  title: "Send this answer?"
  fields: [
    { "kind": "display", "key": "question" },
    { "kind": "display", "key": "answer" },
    { "kind": "approve_reject", "key": "send" }
  ]
  question: ask.content
  answer: draft.answer
}

The fields list builds both the form and the step’s ports. A display field takes a value in and shows it to the reader. An approve_reject field gives you two boolean outputs, send_approved and send_rejected, so you gate what happens next on the one you want:

reply = SendMessage {
  _should_flow: review.send_approved
  text: draft.answer
}

Where the question shows up

Not in VS Code. The graph shows you that something is waiting, with a cyan ring round the box and the word Suspended in its body, and the inspector says Waiting for input.... Answering happens in the Weft tasks browser extension you installed earlier.

Give the extension a way in by minting a token from your project folder:

weft token mint --name "my laptop"

A signal token lets an outside client list a project’s waiting steps and answer them. The full value is printed once, at mint, and never again, so copy it now. --projects and --tags narrow what it can reach, and weft token ls and weft token revoke <id> handle the rest.

Open the extension, go to its settings, and add your runtime: its address and that token. The browser will ask you to allow access to that host, because the extension holds no permission to reach anything until you give it one.

What the person sees

One card at a time. Your title, the values you wired into the display fields, and the buttons or boxes your other fields asked for. The primary button says Submit for a question inside a running program, and Fire for a trigger that starts one.

Beside it, Skip answers with nothing and lets the run carry on, and Cancel run ends the whole execution. Neither shows up on a trigger, because skipping a trigger would fire it.

After a submit the card says Submitted and moves to the next one. Back in the graph, the cyan ring goes green and the rest of your program runs.

Starting a run from a person

HumanTrigger is the same form pointed the other way: instead of parking a run that already exists, it starts a new one when somebody fills it in. It shows up in the extension under trigger tasks rather than resume tasks, and it only appears once you have run weft activate, because a trigger is only listening after you arm it.

That is one of the three lifecycles, and it is the step people forget.

When nobody answers

A parked run waits, with no deadline. HumanQuery has no timeout setting and weft will not invent one, because a question that expires on its own leaves you with a run that ended for a reason nobody wrote down.

So ending it is a decision somebody makes. The person looking at the card can press Cancel run, and you can run weft stop <execution-id>. To find the ones still sitting there:

weft executions --status running

Parked runs come back in that list, with waiting_for_input in the status column rather than running, so you can tell at a glance which ones are working and which ones are waiting on a person.

How a program runs

Do not read a weft program top to bottom. A step can send its answer before it has finished, or sit waiting for a week while the rest of the program carries on. If you want to predict what happens, look at what arrives at each step’s inputs.

Values arrive one at a time

When a step emits, the value travels along every arrow leaving that output. Each delivery is a pulse, aimed at one input, carrying a tag saying which run it belongs to. One output wired to three inputs is three deliveries.

A step waits until every input with an arrow into it has either a value or a closure. Optional inputs count too: an arrow is an arrow. An input can also be satisfied by a value written into the step or by a default, so not everything needs an arrow.

Once every wired input has settled, weft decides whether the step runs or is skipped. A step with nothing wired into it has nothing to wait for, so weft starts it when the run begins.

How a branch stops the steps after it

Here is the problem. A step decided not to send anything. Everything wired to it is now waiting for a value that is never coming. How does the program know the difference between “not yet” and “never”?

A closure is the answer: it is a pulse that says nothing will ever arrive here, for this run. It is how a step says no rather than saying nothing.

It is not null. null is a value, and a port whose type allows it takes it like any other.

For an ordinary step:

What happenedWhat the step does
A required input closedSkips
An optional input closedNothing. The other inputs decide
Every input closedSkips
Every @require_one_of input closedSkips
_should_flow is false, or closedSkips

A skipped step closes its own outputs, which can skip the next step, and the next, until the closures reach something that can carry on with what it has.

flowchart LR
    choice[Switch] -->|true| send[Send]
    choice -. closed .-> review[Review skipped]
    review -. closed .-> archive[Archive skipped]

Streams close differently. A closure on a stream is the stream’s end rather than a skip, so a reader whose stream carried nothing still runs and sees that nothing came.

A failed step closes whatever it had not already sent. Anything it did send is not taken back.

Choosing a branch, and joining back up

Every step has _should_flow, and it defaults to true. Wire a value into it to decide whether that step runs.

Only a literal false says no. Any other value lets the step run, and so does anything the step’s own code would have considered falsy. The step never sees this port: weft checks it before calling in.

Switch gives you one output per case. It tries them in the order you wrote them, and the first one that matches emits true while every other one closes. Wire a case’s output into a step’s _should_flow and that step runs on that case only. If nothing matches and you gave no otherwise, they all close.

To bring two alternative answers back into one arrow, use FirstInOrder. It waits for its wired inputs to settle, then takes the first one that actually supplied a value, in written order. Moving those lines changes which answer wins. Which one arrived first makes no difference.

_should_flow takes one wire, so “only if the model said it was rude and said it was sure” is one gate fed by two conditions. All is the AND: wire both conditions into it and gate on what it emits. It emits when every input arrived and none of them is false, and a branch that closed never arrives at all, so a closed input is not a false.

Running on the thing that did not happen

Everything above skips when its inputs close. So how do you run a step because something did not arrive?

Wire that something into _should_not_flow instead. Every answer flips: a value arriving means the step 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 with no picture: `photo` closes, so this runs.
default_art = FetchToStorage {
  url: "https://example.com/blank.png"
  _should_not_flow: route.photo
}

This is the one port in weft that starts a step on a closure. Reach for it when the absence is data, like a key the caller did not send. When the absence is a decision your own step made, have that step say so on a second output and gate on that, because the wire then reads forwards.

A failure is not an absence. When the step it watches fails, its ports close too, but that closure carries which step broke and its error, and the gate reads it: the step stays off, skipped with the reason what its _should_not_flow watches did not finish ('query' failed: the database is down), and the run reports the failure. So a “nothing there” branch never runs over a database that is down. The same holds through a group or a loop: a failure inside closes the scope’s outputs with the failure on them, so a watcher outside reads it as one (and a loop whose carried value is lost to a failure stops and fails, see loops). And it holds through a skip: a step that skipped because its input closed on a failure did not decline either, so its own ports close with that same failure on them (its skip reason ends in because 'query' failed: ...), and a watcher two steps down still reads a failure, not an absence, and still sees query named as the step that broke rather than the step that skipped.

A step has one gate. Wiring both spellings is the two-gates error.

One step, several runs at once

A firing is one go at one step. Three things say which firing you are looking at: the run, the step, and the loop iterations it sits inside. Inside a parallel loop the same step can have several firings alive at the same time.

Those iteration numbers are called frames. Work at the top level has none, and work inside two nested loops carries one frame for each. weft only ever combines inputs whose run and frames match, which is what stops iteration three eating iteration four’s answer.

An ordinary output emits at most once per firing, though it can emit on one output now and another later. Whatever it never mentions is closed when it finishes. Generators and buses have their own rules, in streams and buses.

Groups and loops are gone before anything runs

The compiler turns each group into a pair of boundary steps, each loop into a LoopIn and a LoopOut, and each included file into a call pair plus one shared body. What the runtime gets is one flat graph with the scope information attached.

So folding a group in the editor costs nothing at run time, and neither does nesting groups five deep. For the boundary rules, go and read groups and loops.

What happens when you hit run

A manual run starts every step at the top level that has nothing wired into it, plus every trigger in the project, wired or not. Triggers get no event on a run like that, so they close their outputs, and only the paths that do not need an event go anywhere.

To run part of a project:

weft run --target daily_report --target alert

weft takes those steps and everything feeding into them, stopping the walk when it reaches a trigger. A step that several branches share does not drag those other branches in. A target inside a group brings only the work needed through it, with the group’s own _should_flow still applied. A loop is the exception: it comes along whole, and cutting inside one is refused.

You can also start in the middle, handing values in where the walk begins:

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

A value a real execution produced beats one you handed in, and a producer that is still running is waited for. For which flag picks which part of the graph, go and read versions, seeds and frozen examples.

--group runs the group’s own steps and nothing that feeds it, so a group port wired from outside gets only what you hand it. If a cut leaves an input with nothing, weft looks at who needs it. When a step would skip without it (it is a required input, or the only member of a @require_one_of set that could get a value; the step’s own, or one inside the group it enters, followed through every group, loop and included file on the way), the run is refused before it starts, naming the input and what it feeds:

triage.text gets nothing in this run: ask.text is outside it, and
triage.classify.text cannot run without it. Hand it a value at this start
(--from triage='{"text": ...}'), run what feeds it with --feed triage, or
start further up so ask runs too.

The flag in the message is the one the start was named with: --group for a --group start. An input no start lies in front of is named where the wire lands, and the way to hand it a value is to start there.

The input is named at the start you gave, even when the missing value would cross more doors on the way (a group inside an included file), and a value you hand there feeds everything behind it. A trigger’s own inputs never count: a run that does not fire it never reads them. An input only optional steps read just closes, with a warning.

A start is where the walk upstream stops, for --from and --group alike, so a start’s own ports get only what you hand them. To have weft run what feeds them instead, add --feed:

weft run --from 'hear.note={}' --feed hear.note --target hear.note

For each input of hear.note you did not hand a value, that runs the node feeding it and nothing above that node. The value is followed through every door it crosses, however deeply the groups nest, and a feeder shared by several inputs runs once. A loop’s result comes from the whole loop, so a loop feeding the start runs whole. You can also name the feeders as starts yourself (--from db --from accounts --from hear.note): when one start lies upstream of another, the run keeps both, and --target still leaves out every branch that target does not need.

A start that could only ever skip is refused too. When the start’s _should_flow, or that of a group it sits inside, only comes from triggers the run does not fire, or from outside the run altogether, the gate closes and nothing you started would run, even with every input fed. The refusal names the gate and the trigger, if there is one. Hand the gate a value to run as if the condition held, or --fire the trigger with an event. A value reaches a gate only at its own door: for the start’s own gate that is the start (--from 'triage={"_should_flow": true}'), and for a surrounding group’s it is that group, so the refusal asks you to start there instead, with the flag you used (--from 'outer={"_should_flow": true}', or --group if you ran a group).

Without --target, a run goes everywhere downstream of its starts, so --from db alone runs every branch db feeds. --target narrows that to what the target needs, starting no further up than the starts. It is one shape, not a run that goes until it reaches the target.

On a --seed run the check waits for the seed, since a saved result can supply the input. It only can when the run it reads completed that node and the node’s code has not changed since; a refused seeded run names the run it read.

A trigger firing picks the work downstream of that trigger, plus whatever that work needs upstream, again stopping at other triggers. Only the trigger that fired gets the event; any others close. Two triggers can share the steps in the middle without becoming one run.

In a program with instances, a run is also for one instance or for none (weft run --instance user-42 picks one). For what is checked before such a run starts, go and read programs with instances.

How a run ends

A run is finished when there is no work left and nothing still in flight. It does not need an end step. It can also fail, when a step failed or weft could get no further, or be cancelled by a person or by another run.

A run can pause without ending. Waiting for input means a step is parked on an answer from outside, and the run carries on when that answer comes.

If work is left that can never proceed, weft reports the run as stuck and names the steps and inputs involved. A run parked on a person’s answer is waiting, not stuck.

What survives a restart

As a run goes, the worker writes down each thing that happened, in a log called the journal. Those records are what the graph shows you.

If its worker dies, what happens to a run depends on its trigger’s durable setting: for that, go and read how a run is kept. Before you put side effects around a wait, go and read surviving a restart.

What the build catches

The compiler checks that every arrow’s types fit, that every required input is covered, that no wire crosses a scope boundary it should not, that there are no cycles, and that the loop and channel restrictions hold. It also runs whatever validation rules a step declares for itself.

Some things are checked when you build and some only when you run, so a build can succeed and a value can still be rejected later, when a step emits something its declared output type does not allow.

It cannot check whether a step does useful work. A model can return a perfectly typed answer that is wrong. For every rejection by name, go and read what the compiler refuses. To start writing weft yourself, carry on to syntax.

Syntax

A .weft file names some steps and connects them up. This program puts HELLO in front of you:

message = Text { value: "hello" }
shout = ExecPython(text: String) -> (text: String) {
  text: message.value
  code: "return {'text': text.upper()}"
}
show = Debug { data: shout.text }

message, shout and show are names you choose, unique where they sit. Text, ExecPython and Debug are kinds of step, from the project’s nodes/ folder.

Naming a step

name = NodeType, and braces if you have anything to put inside:

message = Text { value: "hello" }

A step with nothing to configure can drop the braces, so Debug and Debug {} mean the same. You still have to supply whatever that step requires.

A name the catalog does not know is unknown-type. Writing a name does not bring a step into existence; the declaration has to be there.

Connecting things

Write source.output where a value would go:

show = Debug { data: message.value }

Or put it on its own line, which is the same connection:

show.data = message.value

Values travel right to left, into the input on the left, and the type has to fit. Write the wire in the braces whenever you can. The line of its own is for the ports a group, a loop or an @include declares, which you set from outside because their braces hold something else or there are none.

To take one field out of a value, keep going with dots:

show = Debug { data: reader.profile.stats.wpm }

That reads stats.wpm off reader.profile. For the type rules, go and read types.

Writing values in

Inputs take strings, numbers, true, false, null, lists and objects, as long as the type allows it. Lists and objects can spread over several lines, and commas between fields are optional.

message = Text {
  value: "hello"
}

Some inputs only take arrows and some only take written values; the input-accepts diagnostic tells you which. Anything the compiler has to read to work out the step’s shape, such as a form’s field list, has to be written inline: an arrow, @file or @asset cannot supply it.

For text kept in another file, go and read files and reuse.

Triple backticks hold code, or anything with newlines in it:

answer = ExecPython -> (value: Number) {
  code: ```
    return {'value': 42}
  ```
}

You can write a value into an input. Outputs are filled by the step when it runs, so answer.value = 42 in that example is the value-on-output error. The same holds inside a group: connect a child’s output to self.result, and if you want a constant to come out, use something like Text to produce it.

Declaring ports

Inputs in brackets, outputs after ->:

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

ExecPython hands your code a and b and expects a dictionary back, keyed by output name. A key left out sends nothing on that output, and return {} sends nothing at all. A key set to None sends a real null on an output whose type takes Null (last: JsonDict | Null), and nothing on any other output.

If you want the program to carry on when the script raises (a request it makes fails, or it raises on purpose), wire the step’s error output: the exception’s type, message and traceback come out there, and the other outputs send nothing. Left unwired, the exception fails the run. A mistake in the program fails the run either way: code that does not compile, a script that ends without returning a dictionary, a key that is not one of its outputs, or a value of the wrong type for its output. error counts as a key it may not return, since weft fills it; to fail on purpose, raise.

You can only declare the ports a step leaves open for you. Ports whose types are already fixed keep them. Declare inputs, outputs, or both:

answer = LlmInference -> (response: String)
convert = Cast -> (value: Boolean)

Those are fragments: the steps still need their other required inputs before they can run.

Extra inputs

Some steps, ExecPython and FirstInOrder among them, let you invent input names. Writing a key in the body creates that input and works out its type from what you put there:

step = ExecPython -> (out: String) {
  text: draft.answer
  limit: 3
  notes?: review.notes
  code: @file("assets/scripts/step.py")
}

limit becomes a Number. text takes whatever type draft.answer has. A bare null gives it nothing to work from, so that fails.

The ? on notes? makes it optional, so a closed arrow there does not skip the step. That spelling only works when you are creating the port; to make a port optional in a full signature, write notes?: String there instead.

Extra inputs stay in the order you wrote them, which matters for FirstInOrder, since it takes the first one that supplied a value.

The four underscore keys

KeyWhat it does
_label: "Review the draft"Puts a label on the box. A string literal only; it cannot come over a wire
_tags: ["support"]Tags the step, including for deciding which runs a stop reaches
_should_flow: decision.approvedDecides whether the step runs. Takes true, false, or an arrow
_should_not_flow: decision.sentThe same decision inverted: the step runs when the thing wired here did NOT happen

Any other key starting with _ is an error.

send = SlackSendMessage {
  _should_flow: review.approved
  channel: "#support"
  text: draft.answer
}

A step has one gate, and wiring both spellings is the two-gates error. For what each one does to the steps after it, go and read how a program runs.

Groups and loops

A group’s braces hold its children, and its signature says what the outside can see:

shout = Group(text: String) -> (result: String) {
  # Uppercase the message
  convert = ExecPython(text: String) -> (text: String) {
    text: self.text
    code: "return {'text': text.upper()}"
  }
  self.result = convert.text
}
shout.text = "hello"

Set a group’s inputs from outside, as shout.text does. self means this group’s own interface. _should_flow can go inside the braces too, and when it skips a group it skips everything in it.

Loops work the same way with iteration settings of their own. Go and read groups and loops.

Declaring a step where you use it

You can write a step straight into the place its value is wanted, ending with .port to say which output you mean:

show = Debug {
  data: Text { value: "hello" }.value
}

That makes the step and the arrow together, exactly as a named declaration would. The .port on the end is required even when there is only one output.

Needing one of several inputs

@require_one_of says a step needs at least one out of a list:

lookup = SlackFindUser {
  @require_one_of(email, id)
  account: slack.access
  email: "person@example.com"
}

Every name in there has to be a real input. Supply none of them and the compiler says require-one-of-unmet. At run time, the step skips if they all close without a value.

It goes in a step’s body or its inline signature. A step’s author can bake the same requirement into its metadata as oneOfRequired. Groups and loops reject it: put it on the child that needs the value.

One copy per instance, one value per instance

@per_instance on its own line in an infra node’s body gives each instance of the program its own copy of that node (its own container):

bridge = BaileyBridge {
  @per_instance
}

It takes no arguments.

@instance_filled goes where a field’s value would, and makes the value each instance’s own: @instance_filled alone, or @instance_filled(<value>) with the value an instance that was given none gets. A connection is a value too:

google = GoogleAccess { account: @instance_filled }
digest = Cron { cron: @instance_filled("0 0 8 * * *") }

The fallback can also be a file, read the way a written one is: personality: @instance_filled(@file("prompts/default.md")).

For what both do, go and read programs with instances.

Comments

# starts a comment. A plain comment as the first thing inside a group or loop body becomes its description, which is what the graph shows when it is folded shut, as in the shout example above. A comment anywhere else is just a comment.

The parser keeps your whitespace and comments, so the editor can change one field without reformatting everything around it. There is no enforced formatting style.

Types

Every port has a type, and the compiler checks every arrow before anything runs. Same colour on two dots is the quick version of what this page explains.

What you can write

String
Number
Boolean
Null
List[Number]
Dict[String, Number]
String | Number
{ role: String, name?: String }
Generator[String]
TypeWhat it holds
String, Number, BooleanThe obvious
NullOrdinary data. A port whose type allows it takes it like any value
Image, Video, Audio, BlobA stored file. Blob is anything that is not the other three: a PDF, a zip
MediaShort for Image | Video | Audio
FileShort for those three plus Blob
List[T]Any number of T
Dict[K, V]Keys and values, all the same type
JsonDictAn object whose values nobody checked
{ a: String, b?: Number }A record, with ? marking a field that may be missing
A | BEither one
AccessA connection to a service. Go and read how connections work
Generator[T]A stream of T. Go and read streams and buses
BusA live channel. Same page
EmptyNothing can be one. See below
MustOverrideNobody has decided yet, and the build stops until you do
T, T1 to T99A type variable. The node works with whatever you connect

A stored file on a wire is never the bytes. It is a small marker saying where the file is, which is why a String holding a URL is not an Image.

What fits into what

FromIntoFits?
StringStringYes, and the same for every other primitive
List[A]List[B]If A fits B
List[T]TNo. To go through a list one at a time, put the consumer in a Loop
RecordJsonDictYes. A record forgets itself
JsonDictRecordNo. Use a Cast, which checks the shape
Dict[String, V]JsonDictYes, and back
RecordRecordEvery field you have must be declared on the target, every required field it wants must be required on yours, and the types must match
AA | BYes
A | BAOnly if both A and B fit A
Generator[A]Generator[B]Only if they fit both ways. A stream is invariant
EmptyanythingYes
anythingEmptyNo

Records are strict. A field the target does not declare is a mismatch, not something quietly carried along. And a field that can be null says so: extra: JsonDict | Null. A JsonDict alone refuses a null, so a query whose rows all have that column empty fails the node with does not accept (got List[Dict[String, String | Null]]), and the fix is the | Null on that field.

Empty is the type of a value that cannot exist. An empty list [] is List[Empty], because there is no item to learn from, and that goes into a List[String] or a List[Number] without complaint. Inside a union it disappears: Number | Empty is just Number.

When a type is still MustOverride, or still a bare type variable, weft lets the arrow through and tells you to decide. That is the must-override-unmet and unresolved-typevar pair.

Giving a type a name

type CustomerId = String
type Ticket = { id: CustomerId, body: String, priority?: Number }

A declaration goes at the top of a scope, meaning the file, or a group’s or a loop’s body. Not inside a node’s braces. Order does not matter. A name is visible in its scope and every scope inside it, included files too: for how a type reaches into an @include, go and read files and reuse.

Names are compared as names. CustomerId and String are different types even though one is the other underneath. That is the point: a customer id cannot be wired into a port that wanted any old string by accident.

A name flows into its underlying shape for free, because the name just falls away. Going the other direction needs a Cast, which checks the value really has that shape.

Nothing shadows. A name already visible cannot be redeclared, even to the same thing. Two catalog packages shipping an identical type is fine and they absorb; two packages shipping the same name with different bodies names both. And a port that restates a declared name with a different body is the named-type-conflict error, because a named type has one body everywhere.

Reading a key off a wire

speed.wpm = reader.profile.stats.wpm

The dotted path goes on the source side only. A wire lands on a port, never on a key inside one.

Each step of the path has to name a field of a record. A JsonDict has no declared fields, so reading a key off one is the deref-path error telling you to declare the type on the source port, or Cast it first.

The interesting case is an optional field. If you read ?-marked key and it is missing, or it is null, the wire closes. It does not deliver null. So the step you wired it into applies its own rules: a required input skips, an optional one lets the others decide. For what a closure does, go and read how a program runs.

A required key that is missing is an error naming the key, never a quiet null.

Cast

Cast is the node for converting a value and checking the result. You declare the type you want on its output, and the compiler tells you whether that conversion exists at all.

FromIntoWhat happens
StringNumber, BooleanParsed
StringList, Dict, JsonDict, a record, a named typeParsed as JSON, then held to the target’s shape
Anything not a file or a live handleStringTurned into text
NumberBoolean1 and 0
Any object shapeA record or a named typeChecked against the declared structure at run time
Any object shapeA file typeThe marker is checked, including that its kind matches
List[A]List[B]If A casts to B. Same for Dict values

If an ordinary arrow is refused and a cast would work, the type-mismatch message says so and tells you to wire it through a Cast.

At run time the check names the exact path that was wrong, like messages[2].role: expected String, got Number, rather than failing with a shrug.

What an unwired port starts as

A port nobody wired and nobody wrote a value into has a zero value, which is what seeds a loop’s carry when you did not give it a starting value.

TypeStarts as
Number0
String""
Booleanfalse
List[T][]
Dict, JsonDict{}
A unionThe zero of its first variant that is not Null
Everything elsenull

Groups

A group holds several steps under one name and folds shut like a folder. Its signature says what the outside can see.

credit = Group(db: Access, user: String) -> (paid: Boolean, refusal: String) {
  # Take one credit off this account, or say why we cannot
  debit = PostgresExecuteQuery(telegram_id: String) {
    query: @file("assets/sql/spend_credit.sql")
    account: self.db
    telegram_id: self.user
  }

  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 = pg.access
credit.user = ask.user

self is the group’s own interface. On the right of an arrow it means the values coming in from outside; on the left it means the values going out. It is always the innermost scope, so inside a loop inside a group, self is the loop.

Set the group’s inputs from outside, on its name, as credit.db does. You cannot write a literal onto self.port: a group’s input is given its value from outside.

A plain comment as the first thing in the body becomes the group’s description, which is what the graph shows when it is folded shut.

They do not exist when the program runs

The compiler turns every group into two nodes, one holding its inputs and one holding its outputs, and hands the runtime one flat graph with the scope written on each node.

So folding a group in the editor costs nothing, nesting five deep costs nothing, and a group is never a thing the runtime has to step through. It is a way of writing and reading, and it is gone by the time anything runs.

When a group starts

The node holding a group’s inputs fires like any other node: once everything the run wires into it has arrived, _should_flow included. A closed input counts as arrived. An included file is a group, so each @include starts the same way.

When it fires, every step inside that no wire feeds starts with it. So a step with no inputs needs no _should_flow to get its turn, and it never runs before the group’s slowest input has landed.

It only runs in a run that takes it along, though. A trigger’s run holds what the trigger feeds plus what those steps read, so a step with no inputs inside a group comes along when something that run uses reads it, or when the run feeds the group’s _should_flow, which brings the whole group in. If several routes feed one include, a route that did not fire never holds it up: it is either left out of the run, or it runs only to close its outputs, and a closure counts as arrived.

Why a required group input does not skip the group

This is the part that surprises people.

You declare db: Access without a ?, so it is required. Then the thing feeding it closes. You would expect the whole group to skip.

It does not. The closure goes inside, and whichever step actually needed that value skips there, and the skip cascades from that step outward. Every port on a group’s boundary is optional, and the requiredness you declared is enforced by the node that consumes it.

That is deliberate. A group is usually several jobs, and one missing input often only kills one of them. Skipping the whole group would throw away work that had everything it needed.

It also means @require_one_of is refused on a group. Put it on the step inside that needs one of those values.

A loop is the exception, for the ports it iterates or threads: with no list to walk, there is no iteration to launch.

When a group input has to be connected

A group port is never required for its own sake, and the ? on it changes nothing. What the compiler checks is whether a step inside needs it.

It follows the port inward, through any group, loop or included file nested behind it, until it reaches the steps that read it. If one of them takes it into a required input, or into the only member of a @require_one_of set that could get a value, and nothing outside connects or writes the group’s port, the build fails with required-port-unmet, naming both ends:

input 'g.b' has nothing connected, and inside it feeds 'g.f.y', a required
input, so 'g.b' is required too: wire or write it

A port that only optional inputs read may stay unconnected: the closure goes inside and each of them handles it the way it handles any closure. An included file’s ports and a loop’s other ports follow the same rule.

Skipping the whole thing

_should_flow goes inside the braces, and when it skips a group it skips everything in it, however deep, including nested groups. One line guards a whole subgraph.

brief = Group(request: String) -> (prompt: String) {
  ...
}
brief.request = ask.text
brief._should_flow = credit.paid

The gate is consumed at the boundary. The steps inside never see it, and they do not each get to decide: they take the group’s decision as a whole.

Only the incoming side takes a gate. The outgoing side has nothing to decide, because a group that did not run has nothing to forward.

What crosses the boundary

ThingCrosses?
Ordinary values, connections, file markers, bus markersYes
A closureYes, and it cascades to whatever inside needed the value
_should_flow and _should_not_flowNo. Consumed at the boundary
A stream (Generator[T])No
A wire from inside to outside that skips the interfaceNo

A stream is a live edge between two running nodes, so both ends have to be in the same scope. If you need a stream to cross, either move the consumer in, or have a node read the stream and emit ordinary values. A loop is the one thing that takes a stream across its edge, through its own over.

That last row is the scope-reachability error. A value crossing the line has to appear on the interface, where somebody reading the group can see it.

Names you cannot use

Group, Loop, Passthrough, LoopIn, LoopOut and self are the language’s. So is any name containing a double underscore, which is the compiler’s own separator.

Naming a group or a node after a node type is also refused, because Ticket.body would then read as an inline node rather than a reference. That is the reserved-name error.

How big a level should be

Six items on one level is what the language asks for. The compiler warns past fifteen, names the level, and tells you to group.

A group counts as one item on its parent’s level, whatever is inside it. So a program grows downward into nested folders rather than sideways into a wall, and every level stays something you can hold in your head.

A file’s top level is counted per connected part. Everything a wire links counts together, a database or a key set that several pieces use included, so the warning reads “one connected part of the top level holds N items”. Only pieces that share no wire at all are counted apart. The inside of a group, a loop and an included file is counted whole (“this included file holds N items”).

Loops

A loop runs what is inside it once per item of a list, or until the body says stop.

grade = Loop(cards: List[JsonDict], rubric: String) -> (scores: List[Number | Null]) {
  over: ["cards"]

  mark = LlmInference -> (response: String) {
    prompt: self.rubric
    ...
  }
  self.scores = Cast { value: mark.response }.value
}
grade.cards = pull.rows

Like a group, a loop is gone before anything runs. The compiler turns it into a pair of boundary nodes and the runtime drives the iterations.

The five settings

They go in the body as ordinary lines. Anything else starting with a letter is a node, and anything else that looks like a setting is a hard error, because a typo like max_itres: 10 silently running your loop uncapped is exactly what the language refuses to allow.

SettingDefaultWhat it does
over: ["cards"][]Which inputs to iterate. Each has to be a List[T] or a Generator[T]
carry: ["total"][]Values threaded from one iteration to the next
parallel: truefalseLaunch every iteration at once, rather than one at a time
max_iters: 50no capA hard limit. 0 is legal and means no iterations
trim_on_mismatch: falsetrueWith several over lists of different lengths: trim to the shortest, or fail

The four kinds of port

Which kind a port is depends on where it is and whether you named it.

KindOutside the loopInside the loop
Iterated, named in overList[T] or Generator[T]One T, a different one each iteration
Broadcast, any other inputTThe same T, every iteration
Gathered, any output not in carryList[T | Null], one slot per iterationYou write one T per iteration
Carried, named in carryThe seed goes in, the final value comes outThe running value in, the new one out

A gathered output has to allow null, because an iteration that failed leaves an empty slot. That is the gather-output-must-be-nullable error.

The list that comes out is built for every gathered port, even one that no iteration ever wrote, because a port that produces nothing at all would leave whatever is downstream waiting forever.

A carried value that an iteration does not write keeps what it had, as long as nothing broke: a branch not taken or a gate that said no is fine. If you do not wire a seed, it starts at the zero for its type: 0 for a number, "" for a string, [] for a list.

Two implicit ports

self.index is a Number, readable inside, and it is which iteration you are in, counting from zero.

self.done is a Boolean you can write inside, and it is how the body votes to stop.

Both only appear if you did not declare a port of that name yourself. And index as an input name, or done as an output name, is the reserved-port-name error.

One at a time, or all at once

Sequential is the default. Each iteration finishes before the next launches, which is what makes carry possible: the next iteration reads the value the last one wrote.

parallel: true launches every iteration up front. Three things it refuses:

  • with carry, because carrying means each turn feeds the next, and that is sequential by definition
  • without over, because there is nothing to spread out
  • with self.done, because a vote to stop means nothing when everything already started

How a loop ends

  • It runs out of items in over.
  • It hits max_iters.
  • The body writes self.done = true, on a sequential loop.
  • A carried value is lost to a failure, and then the loop fails (see below).

A sequential loop with none of those is the loop-unbounded-no-termination error, refused at compile time rather than left to run forever.

With several over lists of different lengths, the default trims to the shortest. Set trim_on_mismatch: false and a mismatch fails the run instead, naming the lengths.

When an iteration fails

If you only gather, the run carries on. That iteration’s slot in every gathered list is null, so a loop over a hundred rows where three fail gives you a hundred slots with three nulls in them. Whatever reads that list decides what a null means.

If the failure means a carried value never got written, the loop stops there and fails. The next iteration would start from the same value and meet the same failure: an agent loop whose model call broke would otherwise pay for the same doomed call up to max_iters times, then hand on a stale history as if all went well. The loop’s step fails with

loop 'agent' stopped at iteration 4: its carried value 'history' could not be updated because 'ask' failed: <the error>

where ask is the step that actually broke, even when other steps between it and the loop’s end skipped because of it. Everything reading the loop’s outputs sees a failure, the same as for any failed step, and ask shows as failed in that iteration too.

A carried value that closed with no failure behind it, because a branch was not taken or a gate said no, still keeps what it had.

What cannot go in one

A trigger and an infra node are both refused inside a loop, with trigger-in-loop and infra-in-loop. A trigger registers once for the project, and infrastructure is provisioned once for the project, so a per-iteration one is not a thing.

Cutting a run inside one

You cannot. weft run --target stops at a loop’s edge and brings the whole loop along, because half an iteration is not a state the runtime has.

Streams and buses

Most wires carry one value. Two things carry more than one, and they are for different jobs.

A stream is one node handing items to one other node, in order, until it ends. A model writing its answer a word at a time.

A bus is several nodes that are all running at once talking to each other. A transcriber, a translator and a display, each doing its part while the audio keeps arriving.

Streams

Write the type on the port and you have one:

rows = PostgresStreamQuery -> (rows: Generator[JsonDict]) { ... }
grade = GradeEachRow { rows: rows.rows }

Items travel as ordinary pulses, each checked against the element type. The consumer starts when the first item arrives and pulls the rest as it goes, so it is running while the producer is still producing.

A closure on a stream is its end, not a skip. This is the one place where a closed wire does not make the consumer skip. It runs, sees the stream is over, and acts on it. A stream that carried nothing is an empty stream, which is a result, not an absence: zero rows has to behave like one row, not like a branch that never happened.

If the producer failed, the consumer finds out through an error rather than a clean end, so nothing mistakes a broken stream for an empty one.

What a stream will not do

Why
Feed two consumersTwo readers on one stream is a question with no answer: do both see every item, or do they race? Fanning out is what a bus is for
Cross a group boundaryIt is a live edge between two running nodes, so both ends have to be in the same scope. A loop takes one through its own over and nowhere else
Be optional, or have a defaultThere is no such thing as an empty stream sitting unwired. A stream input is required
Sit inside a list, dict, record or unionIt is a port type, not a value
Be a loop’s carryA carried value goes through the journal as JSON between iterations, and a stream is a live thing
Be stored, cast, or turned into textSame reason

A producer that runs far ahead of its consumer will fill the buffer: 4096 un-taken items, and then it fails and tells you the three ways out, which are to yield instead of pulse so each item waits to be taken, to let the consumer catch up, or to raise the cap.

Buses

A bus is a channel any number of nodes join. One node makes it and emits a marker, and anything that gets the marker can join.

mic = MicrophoneCapture -> (audio: Bus)
transcribe = Transcribe { audio: mic.audio }
translate = Translate { audio: mic.audio }

The marker is an ordinary value. It crosses group boundaries, goes into loop bodies, sits inside records, and travels like anything else. Only the live channel is special, and it lives in the worker rather than on the wire.

Every participant registers a name, which is the stamp on everything it sends and what other nodes wait for. Registering is the “I am here and ready” moment, so a node with a slow warmup holds the bus and registers when it is genuinely ready rather than when it starts.

Each reader has its own position. Reading does not consume, so ten readers all see everything.

Two modes

Journaled, the defaultEphemeral
What the record keepsEvery messageCounts per sender, and nothing else
A reader that falls behindCan reach back as far as the windowSkips to the oldest message still held
If the record cannot be writtenThe send is refused before anything is appendedNothing is refused

Ephemeral is for a firehose. Video frames should not stall because something downstream is slow, and they should not end up in permanent storage either.

The window is 64 messages either way. What is always kept, in both modes, is who joined and who left, so nothing a reader needs can disappear into a gap.

The record exists so you can read the conversation in the inspector afterwards. It is not what rebuilds a bus, because nothing rebuilds a bus.

A bus lives exactly as long as its worker

It is memory in a process. If that worker goes away, the bus goes with it, and a marker resolved afterwards fails saying the bus is unknown.

That is fine for what it is for, which is nodes that are alive together right now. It is why a run that used a bus cannot be partly reused by a seeded run: weft leaves the producer and its readers out rather than handing you a marker pointing at nothing.

A durable wait is a different thing and the two coexist. A node can park on a question for a week while a bus somewhere else stays up.

What a bus does not check

Payloads. A stream checks every item against its element type; a bus carries whatever you send. You pick JSON or bytes when you make it and that is frozen, but what is inside is between the nodes talking.

Which one

You wantUse
One node’s output, arriving in piecesA stream
Several live nodes talking to each otherA bus
To fan one thing out to several readersA bus
Ordering and type checking on every itemA stream
To survive the worker restartingNeither. Use storage, or a step that ends

For the Rust on both sides, go and read streams and buses in Rust.

Triggers and routes

A trigger is how a program starts without you. A message arriving, a schedule coming round, a form somebody fills in, a request hitting an address.

ask = TelegramReceiveMessage { account: telegram.access }

Wire it like anything else. What comes out of it is the event.

Nothing listens until you run weft activate. Go and read the three lifecycles for why that is its own step.

What a run started by a trigger covers

Everything downstream of the trigger that fired, plus whatever that work needs upstream, stopping the walk when it reaches another trigger.

Only the trigger that actually fired gets the event. Any others in the project close their outputs, so paths that needed them skip.

Two triggers can share the steps in the middle without their runs becoming one.

A trigger’s inputs are frozen

Whatever fed a trigger’s ports when you activated is what it uses on every firing. The nodes upstream do not run again per event.

Everything upstream of every trigger runs at activation as one program, so two steps that do not depend on each other run at the same time. Setup that several triggers share, like the schema all their tables live in, is written once and wired to each of them; two copies of create extension if not exists racing each other is how one of them fails on a duplicate key.

So after changing one, weft resync. Until then your listener carries on with what it registered. weft tells you when a bake is stale:

the code changed since trigger 'inbound' was last prepared, so its bake is
stale. Prepare it again: `weft bake` does it without listening, `weft activate`
does it and listens

Answering on a URL

Route claims an address and starts a run when somebody calls it.

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

reply = Reply {
  body: { "id": saved.id }
}

The caller is held open while your program works, and Reply is what answers them. For streaming an answer as it is produced, and for the Rust side, go and read talking to a live caller.

A path can capture: cards/{id} puts id where your node can read it.

Two routes that one call could reach, where neither is more specific, is an error. cards/count beside cards/{id} is fine, because the first is more specific. Two spellings that genuinely overlap are the route-overlap error, because a call arriving would have no defined answer.

You know a route’s address before you activate: on your machine it is http://127.0.0.1:14111/connect/local/cards (the install’s address, then /connect/local/, then the path). On a cloud install it is the install’s own address, then /connect/local/, then the path.

If you want the fastest way in, or a frontend wants your routes at the root of an address, call the project’s own address, where your program answers with nothing of weft’s in between. On your machine that is a free port the project gets the first time you activate it and keeps from then on (http://127.0.0.1:14200/cards); if another program takes that port, the project moves to another free one. On a cloud install it is the project’s own Cloud Run address. On your machine, if you want a port of your choosing, weft activate --port 8080 opens that one, and the project keeps it for later activates. If another program or another of your projects holds that port, weft activate fails and nothing is activated. weft activate prints the address, and weft status shows it later or says why it is unavailable. Your routes keep answering under /connect/local/ as well.

If a call arrives while the route’s trigger is parked, hibernating within its grace window, or still being set up, it is answered 503 with a Retry-After at once, so a client that retries gets through once the trigger is on. Once the project is switched off (wiped), its own port is closed and /connect/local/<path> answers 404.

How a run is kept

Your runs happen inside a worker: a copy of your compiled program, running as a Docker container on your machine, or as a Cloud Run service on a cloud install. A worker keeps the runs it is working on in its memory. A trigger has inputs that decide what happens to those runs: whether they survive their worker dying, whether they are written down, how long they are kept once they end, and how long a wait holds when the run cannot pause. You write them in the trigger’s braces like any other input:

pay = Route -> (body: JsonDict) {
  path: "pay"
  method: "POST"
  durable: true
}
InputDefaultChange it when
durableoffa run has to carry on after its worker dies, instead of ending
recordedona page asks a route for something every few seconds (turn it off)
outlivesCalleroffa run answers its caller early and keeps working (only on a trigger that holds a caller on the line, like Route and Socket)
keepRunsForthe project’s, else a weekyou want its runs kept longer or shorter once they end (12h, 30d, forever). For the project-wide default, go and read how long a run is kept
holdSecs60a run that cannot pause should wait longer for an answer (up to 30 days), or not at all (0). See when a run cannot pause

By default (durable off), a run is fast: it only waits for the database when it has to. That is when it pauses (a timer, a form), and in a few other places (for the full list, go and read when a run waits for its writes). The rest of the time its steps live in the worker’s memory, and its record (the journal that weft events and the editor read) is written a moment after each step. If its worker dies, the run ends cancelled and is not run again. For why, and for what happens when the platform shuts a worker down on purpose, go and read what happens when something dies.

If a run must not end when its worker dies (it moves money, say), turn durable on. Before each step starts, everything the run did so far is written to the database, and a route’s answer is written before it is sent. The exception is a step of a pure node (Text, JsonObject, Switch, Reply), which does nothing outside the run but answer its caller and handle the run’s own files, and starts without waiting.

If the worker dies mid-run, another worker picks the run up where it stopped: every finished step stays finished, and a step that was in the middle of its work is failed with a message saying it may have partly happened, so you know which step to check. The exception is a step of a pure node that takes no stream and had not passed anything on yet: it simply runs again. If a caller started the run, its connection was on the old worker and is gone: the run carries on in the new worker with nobody on the line, so a step that answers the caller fails, saying no live caller is attached. If the platform stops the worker instead, a run that can pause gets 5 seconds for the steps already running to finish, and only a step still running after that is failed. A run that cannot pause (below) keeps running on the stopping worker for as long as the platform lets it, and if the worker goes before the run ends, that is the same as the worker dying. If you want to know what catchErrors does with that failure, or what happens to a step that was waiting on an answer or reading a stream, go and read surviving a restart.

Even with durable off, a run waiting on a timer or a form, with nothing else of it running, survives its worker, because its whole record is written before it pauses. A run that cannot pause is the exception, below.

When a run cannot pause

Three kinds of run cannot pause: one whose caller is still on the line while its trigger leaves outlivesCaller off (the caller cannot follow it), one with recorded off (there is no record to pick it back up from), and one with a bus between its nodes open (a bus lives in its worker’s memory alone).

When such a run reaches a timer or a form, the wait holds in the node’s call instead. The wait is registered as usual, the run stays on its worker, and the answer carries it on right there. The hold clock only runs while the run is quiet, meaning every step still running is waiting, on an answer or on a bus. Anything that moves (a bus message, a step finishing) starts it over. If the run stays quiet for holdSecs (60 seconds unless the trigger says otherwise), the call that was waiting fails, saying it gave up, and the wait is withdrawn, so a form is no longer offered. The node can handle that error like any other, or send it to error with catchErrors. With holdSecs: 0, such a wait fails at once.

If the run becomes able to pause while a wait holds (the last bus between its nodes closes), the wait pauses after all, the way it would have from the start. A run whose caller leaves while outlivesCaller is off is cancelled, as always. And a fast run holding a wait still ends with its worker.

If a page asks a route for a status every few seconds, turn recorded off, or weft executions fills with hundreds of runs. weft keeps no history of such a run, not even in its worker’s memory. If it fails, weft executions lists it with the step that failed and why, and nothing of what ran before. If it reports a cost, or asks weft for something on its behalf that its worker does not already hold (a stored file, a new connection or infrastructure address, a task such as starting or stopping its infra, a stop through ctx.stop_tagged, a wait on a timer or a form), it is listed with its costs and how it ended. Otherwise it leaves nothing. If you want to check that a trigger with recorded off is being called at all, weft status shows how many runs each trigger started in the last minute or two, and how many of them failed.

An unrecorded run cannot pause, so a timer or a form in it holds (when a run cannot pause). If a node tags it with ctx.tag_execution (a label you later find or stop runs by), the node fails: an unrecorded run has no record a tag could point at. The compiler also refuses durable: true with recorded: false.

On a cloud install, one stretch of a run lasts an hour at most. Cloud Run cuts a request at 60 minutes, so a run stops itself 59 minutes after a worker starts it. It ends cancelled with a message saying so, instead of being cut off mid-step with no ending written. A run that pauses whole (every branch waiting on a timer or a form) starts a fresh hour when it picks back up; one branch waiting while another runs does not. So if a job can take longer than an hour, split it with a pause. On your machine no run is cut.

A run that answers a caller lives on the worker the caller reached. While the caller is on the line, the run stops at 59 minutes too, with a message telling you to have the client reconnect. Once the caller has gone, a route with outlivesCaller on keeps its run going, and on a cloud install the run moves: it starts no new step on that worker, and once the steps it was running end, it carries on under a call weft holds open for it, with a fresh hour. Moving works from the run’s record, so the compiler refuses outlivesCaller: true with recorded: false. A run with a bus open cannot move, since a bus lives in its worker’s memory alone, so it keeps running on that worker for as long as the worker stays up.

Answering on a socket

Socket is the same idea for a WebSocket: the caller connects, your program runs, and the two talk until one of them stops.

A client that can send headers (a server, a script, an app) opens its socket at the route’s address and is checked like any other call. A browser can’t put a credential on a socket’s opening request, so on a route that checks callers it asks first with a plain GET, which carries the credential, and gets back {"url": "...", "protocol": "websocket"}; it opens its socket at that url within a couple of minutes.

On a cloud install, one connection lasts an hour at most. Google cuts any request to weft at 60 minutes, and an open socket is one long request, so a socket (or a streaming answer) that is still open then is closed. A connection can also drop sooner for ordinary reasons: a phone changing networks, a laptop going to sleep. When it does, the route’s outlivesCaller decides what happens to the run: off (the default), the run stops; on, it carries on and what it sends goes nowhere.

So if you want a conversation to last longer than one connection, keep what it needs outside the run, and have the client reconnect:

  • the client picks a session id once (or the program hands it one in its first message) and sends it on every connection, as a query parameter or in the first message;
  • the program keeps the conversation’s state in its own storage, keyed by that id (a table in the project’s Postgres, a file), and reads it back when a connection arrives with an id it knows;
  • the client reconnects whenever its socket closes, with the same id.

Each connection is then its own run, and losing one, at the hour or before, loses nothing the conversation needs. If your client is a web page, the connect library’s openLiveSocket does the client’s half: it keeps the session id, sends it as the session query parameter on every connection (your program reads it off the trigger’s query port), and reconnects until the page closes the socket, your program closes it with code 4000, which means the conversation is over (a run that simply ends closes with 1000, and that ends only its connection), or, when the page passes credentials, the route refuses them. A browser is told nothing about a socket refused at its opening (it sees only that it closed), so a socket opened without credentials keeps trying, at most every 15 seconds.

Which triggers need a public address

Only the ones a provider pushes to.

The triggerNeeds a public address?
A timer or a scheduleNo
Something weft pollsNo
A socket weft dials out toNo
A form somebody fills inNo
A provider pushing events at youYes
A Route or Socket that strangers callYes

./setup.sh --public-url opens one. Go and read a public address.

What a trigger cannot do

Why
Be wired from another triggerA trigger’s inputs are frozen at setup, so another trigger’s output could never reach it
Have an infra node downstreamProvisioning happens before any event exists
Sit inside a loopIt registers once for the project, not once per item

All three are compile errors, named trigger-into-trigger, trigger-into-infra and trigger-in-loop.

Firing one by hand

While you are building, you do not want to expose anything:

weft bake
weft run --fire inbound='{"chatId":"123","text":"hello"}'

weft bake prepares every trigger’s settings and starts no listeners. --fire then runs exactly one, with an event you typed.

The payload is checked against what that trigger declared it fires with, both ways: a missing field is refused, and so is one you invented.

A run you fire this way follows the trigger’s durable and keepRunsFor, and it is always recorded, even when the trigger has recorded off, so you can find it in weft executions. For the flags that change how one run is kept, go and read the weft run flags.

For writing a trigger of your own, go and read writing a trigger.

Files and reuse

Three markers pull something in from outside the line you are writing.

What it brings inWrites back?
@include("triage.weft")Another weft file, as one groupNo. The editor opens that file instead
@file("assets/prompts/triage.md")A file’s text, as a valueYes. Editing the field edits the file
@asset("assets/logo.png", Image)A file, a URL or a stored file, as a valueNo. Pull only

Where a path is measured from

This catches people, so it is worth stating on its own.

@file and @asset are measured from the project root, wherever you write them. @file("assets/prompts/triage.md") means the same file in src/main.weft, in src/billing/charge.weft, and in a file included from either.

@include is measured from the file that writes it, like an import in any other language. So @include("../lib/clean.weft") inside src/billing/charge.weft climbs one level from src/billing/.

Both refuse a path that climbs out of the project, and @include refuses it on the spelling before it even looks, so a ../ that escapes is refused whether or not anything is there.

@asset is the exception: it may name a file anywhere on your machine by an absolute path, or under ~, and it is read from where it sits and uploaded when you build.

@file

write = LlmInference {
  prompt: @file("assets/prompts/image_brief.md")
}

The type is String unless you say otherwise, and a String is taken verbatim: no parsing, no trimming. That is the common case, which is a prompt or a document. Give it another type and the text is parsed and checked: @file("limit.txt", Number).

It goes both ways. The editor shows that field as backed by a file, and editing it writes the file rather than pasting the text into your .weft. So a prompt lives in a real file that a person can open, and the graph is still the place you edit it.

That is also why a @file cannot name a URL or a file type. There would be nothing to write back to. The error points you at @asset.

@asset

badge = Text {
  value: @asset("https://example.com/terms.txt")
}
stamp = Watermark {
  image: @asset("assets/logo.png", Image)
}

The type is required for a file, and it has to name one concrete kind: Image, Video, Audio or Blob. File and Media are refused, because a value carries exactly one marker and those leave it open. weft will not guess the kind from the extension or from the bytes.

It is fetched or read when you build, not when you write it, so a URL is resolved once into your project rather than every run.

In the editor, dropping a file onto a node picks it up in place if it has a path on disk, and stores it under your project’s assets/ if it arrived as bytes with no path.

@include

An included file is one group, and its header is the interface:

# triage.weft
Group(email: JsonDict) -> (severity: String) {
  # Classify an inbound ticket
  ...
}
# main.weft
triage = @include("triage.weft")
triage.email = inbox.message

The file has to be exactly one anonymous group, with nothing beside it. No loose nodes, no top-level connections, no top-level includes, not two groups, and not a named one. The group’s header is the file’s interface and the file name is its identity, so anything else would be quietly thrown away, which is why weft checks rather than assumes.

Includes inside that group’s body are fine, and they resolve against that file’s own folder. A file that ends up including itself, through however many hops, is a compile error naming the cycle.

Ten includes, one body

A file is compiled once, however many places include it. Each @include is a call: the values on its ports go into the one body under a frame naming the call site, the body runs, and the 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. The two nest freely, so a loop inside an include inside a loop is just a deeper stack.

Naming a node inside one

An included file has no name you write. Its nodes are named through the site: triage.classify is the node classify of the file that triage includes, and only that use of it.

That spelling is the same everywhere. It is what weft events prints, what --node triage.classify filters on, what weft run --group triage runs, and what the box header shows in the editor. Include the same file twice and the two read apart, as triage.classify and again.classify.

Cutting a run works inside a call too, so --target triage.classify runs inside that one call.

Types reach down into an included file

A type declared above an @include is visible inside the file it pulls in, the same way it is visible inside a group written in place. Declare Settings once at the top of main.weft, and bot.weft and facts.weft can both use it. It keeps going down: a file bot.weft includes sees it too.

It only goes down. A type declared inside a group reaches the includes inside that group and nothing beside it, and a type an included file declares stays in that file. Since nothing shadows, an included file can’t declare a name its including file already has.

A file included from several places has to find each type it uses at every one of them. If one place doesn’t have it, the build says so on the file’s own line. When you open an included file on its own, weft reads it with the types of the first place that includes it, so it looks the way the build compiles it.

The including file doesn’t see an included file’s types, but the call site’s ports still type-check, because a named type carries its shape along with its name.

If two files use the same type name with different shapes, that is the named-type-conflict error. A named type has one body everywhere.

Where each belongs

Put a prompt, a SQL query or a script in assets/ and pull it in with @file, so somebody can open it, read it and edit it without reading your graph.

Put a picture, a sound or a document in with @asset.

Pull out a group into its own file with @include when it gets big enough to stop fitting on a screen, or when two places need it. For what that costs at run time, which is nothing, go and read groups.

What the compiler refuses

Every compiler finding carries a slug naming the rule, and a message naming the fix. The slug is the stable part, so this page lists all of them.

Everything here is checked when you build, with one exception: rule-runtime is only checked by weft validate and by the editor just before you Run, Activate or Resync. That is deliberate, so a half-wired sketch with no credentials connected still builds while you work on it.

Almost everything is an error. The three warnings are marked.

Before the rest can run

SlugWhat it foundWhat to do
parseThe source did not parseRead the message; it names the span and the syntax that failed
enrichA node’s ports could not be worked out: a bad type, or a custom port on a node that does not take themFix the port declaration on the node named
unknown-type (warning)A node names a type the catalog does not haveCheck the spelling, or add the node under nodes/. If a folder has a metadata.json and no mod.rs, the message says so

parse and enrich are buckets: they carry the underlying message rather than a slug per cause.

Wires

SlugWhat it foundWhat to do
unknown-source-nodeA wire reads from a node that does not existFix the name
unknown-target-nodeA wire writes to a node that does not existFix the name
unknown-source-portA wire reads a port the source does not declareFix it; the message lists the real ports
unknown-target-portA wire writes a port the target does not declareSame
duplicate-input-portTwo wires land on one inputRemove one; the message names the first one’s line
double-driven-portAn input is both wired and written into the bodyRemove one of the two
scope-reachabilityA wire crosses a group or loop boundary without going through its portsRoute it through the group’s own ports
deref-pathThe dotted path on a wire cannot be read off the source’s typeFix the path, or change the source type
type-mismatchThe source’s type does not fit the target’sChange one side, or cast. The message suggests a cast where one exists
must-override-unmetA connected port is still MustOverrideDeclare a real type in the source
unresolved-typevarA connected port still has a type variable nothing pinned downConnect it to something concrete, or declare the type
cast-not-allowedA cast node’s two ports are a pair the conversion table does not allowPick types the table allows
graph-cycleThe wires form a cycleIterate with a Loop, or exchange feedback over a bus
required-port-unmetA required input has no wire, no written value and no default. On a group, loop or included file: a port nothing outside connects, whose value a step inside cannot run without (a required input, or the last possible member of a @require_one_of set)Give it one of the three; for a group port, connect or write it from outside

Values you wrote

SlugWhat it foundWhat to do
config-type-mismatchA written value does not fit the input’s typeFix the value, or the declared type
input-acceptsThe port does not take this kind of driver: a wire on a literal-only port, a written value on a wire-only portUse the one it takes; the message lists them
gate-not-booleanA written _should_flow or _should_not_flow is not a BooleanWrite true or false, or wire it
literal-out-of-rangeA written number is outside the input’s min and max, or off its whole-number stepWrite a value inside the declared range
literal-not-an-optionA written value is not one of the input’s dropdown options (for a multiselect, one item is not). A dropdown marked free_text never raises itWrite one of the options; the message lists them
value-on-outputYou wrote a value for an output portRemove it. Outputs are filled by the node, and you read them with node.port
undeclared-port-no-customA key names no input, on a node that does not take added inputsFix the key, or use a node with canAddInputPorts
config-null-literal (warning)A key is written key: null, which does nothingLeave the key out and let the default apply
require-one-of-unknown-port@require_one_of(...) names a port the node does not haveFix it; the message lists the real inputs
require-one-of-unmetNone of the ports in a @require_one_of group is drivenDrive at least one

Names and shape

SlugWhat it foundWhat to do
duplicate-node-idTwo nodes share an id once the graph is flattenedRename one; the message names the first one’s line
duplicate-portA node has two ports of the same name on one sideRename one
reserved-nameA node or group is named after a node type, so name.port would read as an inline nodeRename it
two-gatesA node carries both _should_flow and _should_not_flowKeep the one that says what you mean
no-required-skip (warning)A node has inputs but none is required, so it runs even when everything upstream is nullMark one required, or add @require_one_of
level-too-large (warning)One level of the graph holds more than fifteen itemsGroup the nodes that cooperate on one job. Nesting a group costs nothing at run time

Ports that come from a list

For a node whose ports are built from entries you write, like a form’s fields or a switch’s cases.

SlugWhat it foundWhat to do
config-ports-not-a-listThe key holding those entries is not a listWrite it as a list
config-entry-not-an-objectOne entry is not an objectWrite it as an object
unknown-config-entry-kindAn entry’s kind is not one this node offersUse one the message lists
config-entry-without-a-portAn entry does not say which port it addsAdd the key naming the port
unknown-config-entry-keyAn entry carries a key that kind does not takeRemove it, or use one the message lists
config-entry-bad-valueAn entry’s value is the wrong shape, or a value the matched input could never holdFill in a value of the right shape
config-entry-missing-valueAn entry is missing a value that kind requiresAdd the key the message names
duplicate-catch-allTwo entries match anything, so the second is unreachableKeep one
catch-all-not-lastEntries sit after a catch-all, where nothing can reach themMove the catch-all last

Loops

SlugWhat it foundWhat to do
loop-unknown-config-fieldA key that is not parallel, over, carry, max_iters or trim_on_mismatchFix the name
loop-parallel-not-booleanparallel is not true or falseWrite one of them
loop-max-iters-not-integermax_iters is not a whole number of zero or moreWrite one
loop-trim-not-booleantrim_on_mismatch is not a booleanWrite true or false
loop-over-unknown-portover names an input that does not existFix the name
over-not-a-listAn over port is neither List[T] nor Generator[T]Declare it as one
loop-carry-unknown-portcarry names an output that does not existFix the name
carry-port-type-mismatchA carry port’s two sides have different typesMake them the same
over-and-carry-overlapA port is in both over and carryPick one role
parallel-with-carryparallel: true with carry ports. Carrying means each turn feeds the next, which is sequentialDrop the carry, or drop the parallel
parallel-without-overparallel: true with nothing to spread outGive it a non-empty over
parallel-with-doneA parallel loop writes self.doneRemove it, or make the loop sequential
loop-unbounded-no-terminationA sequential loop with no over, no max_iters, and no self.done. Nothing would ever stop itIterate a list, cap the count, or wire self.done
gather-output-must-be-nullableA gather output is not List[T | Null]. An iteration that failed leaves an empty slotDeclare it List[T | Null]
reserved-port-nameA loop boundary declares index or done, which the language ownsRename the port
loop-boundary-unpairedA LoopIn without its LoopOut, or the reverseThis one is a compiler bug, not yours. Please report it
loop-config-missing-parallelThe loop’s config did not come through loweringAlso a compiler bug. Please report it

Streams

SlugWhat it foundWhat to do
generator-in-containerGenerator[T] nested inside a list, dict, record or unionA stream is a port type. Declare it at the top level of the port
generator-input-must-be-requiredA stream input is optional or has a default. A stream has no empty valueMark it required and drop the default
generator-multiple-consumersA stream output feeds more than one input. A stream has one takerWire it to exactly one. To broadcast, use a Bus
generator-through-groupA stream crosses a group or loop boundaryKeep the producer and the consumer in the same scope. A loop takes a stream only through its over
generator-into-generic-portA stream is wired into a port whose type is unresolved and not a streamDeclare the target Generator[T], or consume the stream and emit values
generator-not-iteratedA loop takes a stream that is not in over. A stream cannot broadcast into a bodyAdd it to over
generator-not-carriableA carry port is a stream. A stream is a live edge and cannot cross iterationsCarry a value instead
over-stream-not-aloneA stream in over beside other over portsMake it the only one, and zip upstream if you need more

Triggers, infrastructure and addresses

SlugWhat it foundWhat to do
run-settingsA trigger’s durable, recorded or outlivesCaller is not true or false, or it is durable or outlives its caller and is not recordedWrite true or false; turn recorded back on, or the other one off
trigger-into-triggerA trigger is wired into another triggerRemove the wire. A trigger’s inputs are frozen at setup, so another trigger’s output could never reach it
trigger-into-infraAn infra node sits downstream of a triggerRemove the wire. Provisioning happens before any event exists
trigger-in-loopA trigger inside a loop bodyMove it out. A trigger registers once and fires outside any iteration
infra-in-loopAn infra node inside a loop bodyMove it out. Infra is provisioned once for the project, not once per item
route-path-missingA node claiming a public address has none, or an empty oneGive it a path, like hooks/stripe or chat/{room}
route-path-invalidThe path is not a servable patternThe message says what is wrong
route-method-unknownThat is not an HTTP methodFix it
route-overlapTwo nodes claim addresses one call could reach, and neither is more specificMake one spell out what the other captures, or change a path or a method

Instances

SlugWhat it foundWhat to do
per-instance-ineligible@per_instance on a node that runs no container of its own. Only an infra node can carry itRemove it; for a value each instance gets (its connection included), write @instance_filled on the field
instance-filled-wiredAn @instance_filled field also has a wire into itRemove the wire, or the marker
instance-filled-boundary@instance_filled on a group’s or included file’s own portWrite it on the input of the node inside that reads the value
instance-filled-not-an-input@instance_filled on a key that is not one of the node’s inputsPut it on an input

For what @per_instance and @instance_filled do, go and read Programs with instances.

Types you named

SlugWhat it foundWhat to do
named-type-conflictA named type appears with two different bodiesMake every use of the name carry the same body

Rules a node declared for itself

SlugWhat it foundWhat to do
rule-structuralA rule the node’s own author wrote, checked on every buildWhatever its message says
rule-runtimeThe same, but only checked by weft validate and just before you runFill what the message names. A step with no connection picked is not one of these: the install refuses that run when it starts, naming weft connect

Both take their severity from the rule, so either can be an error, a warning, a note or a hint.

Things that have no slug

Some refusals happen before the compiler gets a graph at all, so there is nothing to look up.

A broken metadata.json fails when the catalog loads, with the path and a sentence. A fire payload that does not match a trigger’s firesWith is refused at run time, not at build time. And in the editor, a node whose metadata is mid-edit is skipped with a warning rather than blanking your graph.

What a node is

A folder with two files in it.

word_count/
  metadata.json    what it takes and what it gives back
  mod.rs           what it does

The metadata.json is what makes the folder a node. Put one anywhere under your project’s nodes/ directory and weft finds it.

FileNeededWhat it is
metadata.jsonYesThe declared surface: ports, types, settings. Go and read metadata.json
mod.rsYes, to runThe Rust: one impl Node
deps.tomlNoExtra crates, system packages, build environment
tests.rsNoIts own tests. Go and read testing a node
images/<name>/DockerfileInfra nodes onlyA container this node needs built

A folder with metadata and no code

That is not an error. weft finds it, reports it as pending, and leaves it out of the build. A program that never names it builds fine, and a program that does gets told which folder is waiting for its Rust.

So writing the interface first and the body second is a supported way to work. It is also worth knowing when a node you are sure exists comes back as an unknown type: check whether its mod.rs is there.

Packages

Drop a package.toml in a folder and it becomes a package. Every subfolder holding a metadata.json is then one of its nodes, found automatically. There is no list to maintain.

slack/
  package.toml        [package] name = "slack", plus shared dependencies
  api.rs              shared code, reached from a node as `super::api`
  metadata.json       optional defaults every node here inherits
  access/
    metadata.json
    mod.rs
  send_message/
    metadata.json
    mod.rs

Package defaults merge key by key, and a node’s own value wins. type, label and description can never be defaults, because they are one node’s identity, and the compiler refuses a package file carrying any of them.

Two nodes with the same type fail loudly rather than one shadowing the other.

The trait

#[async_trait]
pub trait Node: NodeManifest + Send + Sync {
    async fn run(&self, ctx: ExecutionContext) -> WeftResult<()>;
    // everything below has a default
    fn node_type(&self) -> &'static str;
    async fn provision_infra(&self, ctx: InfraProvisionContext, input: ValueBag) -> WeftResult<InfraSpec>;
    async fn setup_trigger(&self, ctx: ExecutionContext) -> WeftResult<()>;
    fn tests(&self) -> Vec<NodeTest>;
}

run is the only one you have to write.

provision_infra and setup_trigger have defaults that fail loudly, naming your node, if your metadata said you implement them and you did not. So requires_infra: true without a provision_infra is an error you get told about, not a silent nothing.

You never check which phase you are in. The engine reads your metadata and calls the right body. For a plain node that means run is called in every phase, including while a trigger is being set up, because a value feeding a trigger’s settings has to be produced then too. For a trigger, run only fires on a real event, with the payload on ctx.wake.

How it gets into the binary

There is no registration file. The compiler walks your nodes/ folder, finds the metadata, and generates the lookup table. Adding a node is adding a folder.

It only generates what your program references, and it emits one cargo crate per package, so editing one node recompiles that package and relinks rather than rebuilding everything.

Your program’s definition is not baked into the binary. The worker fetches it by hash at run time, which is why rewiring your graph without touching any Rust is a cache hit on the image.

The one macro

#[derive(NodeManifest)]
pub struct TextNode;

That reads the metadata.json sitting beside your file and attaches it to your type. It reads the package defaults too, so what your code sees at run time is the same merged document the compiler saw.

It checks at compile time that the file is there, is valid JSON, is an object, and has a string type. Editing the JSON rebuilds the crate.

The struct’s name is how it finds the file, so TextNode has to live in the same folder as its metadata. By convention the struct is the node’s type with Node on the end.

Next, write one.

Your first node

Here is the smallest node in the catalog, whole. Text takes a string you typed and sends it on.

catalog/basic/text/metadata.json:

{
  "type": "Text",
  "label": "Text",
  "description": "Emit a literal string. Useful for prompts, labels, and config values.",
  "tags": ["literal", "string"],
  "icon": "Type",
  "color": "#64748b",
  "inputs": [
    { "name": "value", "type": "String", "widget": { "kind": "textarea" },
      "required": true, "label": "Value" }
  ],
  "outputs": [ { "name": "value", "type": "String" } ],
  "requires_infra": false
}

catalog/basic/text/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
    }
}

Read an input, send an output. That is the shape of every node.

Write one

Make a folder under your project’s nodes/, anywhere except nodes/base_catalog/. That folder holds the standard library, and it is a folder of nodes like any other: weft loads and builds it the same way, and you can delete it if you do not use it. The one thing particular to it is that weft catalog update replaces it with the installed weft’s copy, so anything of yours in there would be lost.

nodes/word_count/
  metadata.json
  mod.rs

metadata.json:

{
  "type": "WordCount",
  "label": "Count words",
  "description": "Count the words in a piece of text.",
  "tags": ["text"],
  "inputs": [
    { "name": "text", "type": "String", "required": true, "label": "Text" }
  ],
  "outputs": [
    { "name": "count", "type": "Number" },
    { "name": "longest", "type": "String" }
  ]
}

mod.rs:

//! Count the words in a piece of text, and find the longest one.

use async_trait::async_trait;

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

#[derive(NodeManifest)]
pub struct WordCountNode;

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

        let words: Vec<&str> = text.split_whitespace().collect();
        let longest = words.iter().max_by_key(|w| w.len()).copied().unwrap_or("");

        ctx.pulse_downstream(
            NodeOutput::new()
                .set("count", words.len() as f64)
                .set("longest", longest)
        ).await
    }
}

Now use it. The type name from your metadata is the name you write:

words = WordCount { text: draft.answer }
show = Debug { data: words.count }

weft run compiles your node into the program and runs it.

Four things that would have bitten you

Do not check your inputs. No asserting that text is really a string, no unwrapping something you were already promised. Every wire was checked before anything ran. A wrong value fails before it reaches your code.

Do not use .get(...).unwrap_or(...). That turns a real type error into your default and you never find out. ctx.inputs.get_or("limit", 10) is the same shape and fails honestly.

Emit each port at most once. Several calls are fine if they touch different ports, which is how you release a value early and a done flag at the end. Touching one port twice is an error naming it. Anything you never mention is closed for you when the body returns, and that closure is what tells everything downstream to stop waiting.

Keep values small. A value over 100 KB fails the emit, naming the port. Bytes go in storage, and what travels the wire is the marker saying where they are.

Failing well

let body = response.text().await.node_err("reading the reply")?;

.node_err("...") wraps somebody else’s error with what you were doing. On an Option, the message is what the reader sees when it was None, so say what was missing.

weft::node_bail!("pick a destination: a channel or a user");

node_bail! is for something your own code worked out. Write the message as an instruction, because it is going in front of whoever is building the program.

Your node never names a weft error type. Those two, plus ? on anything the ctx hands back, cover it.

Then a test

#[cfg(feature = "node-tests")]
mod tests;

and in your impl Node:

#[cfg(feature = "node-tests")]
fn tests(&self) -> Vec<weft::NodeTest> {
    tests::tests()
}

tests.rs:

pub fn tests() -> Vec<NodeTest> {
    vec![NodeTest::fake("counts_words", counts_words)]
}

async fn counts_words(rig: FakeRig) -> WeftResult<()> {
    let outcome = rig.run(&WordCountNode, json!({ "text": "one two three" })).await.ok()?;
    assert_eq!(outcome.outputs["count"], json!(3.0));
    assert_eq!(outcome.outputs["longest"], json!("three"));
    Ok(())
}
weft test-node WordCount

For the tiers, the rig and what a live test costs, go and read testing a node.

metadata.json

Every node has one, beside its mod.rs. It declares the node’s name, its ports, and everything the editor and the compiler need to know about it without reading your Rust.

Unknown keys are refused at every level, so a typo fails the build rather than being quietly ignored. Types are parsed when the file loads, so a misspelled type name fails then too, not on some later run.

Three keys are required: type, label and description.

The top level

KeyTypeDefaultWhat it does
typeStringrequiredThe node’s catalog id. Has to be a valid Rust identifier
labelStringrequiredThe name shown in the editor
descriptionStringrequiredOne line, shown to people and given to the AI builder
tagsList[String][]Search words for the node picker
iconStringnoneA lucide icon name
colorStringnoneHex, a CSS variable, or a palette token
inputsList[]Everything the node takes, wired data and written settings alike
outputsList[]The ports it emits on
requires_infraBooleanfalseThe node provisions containers and must implement provision_infra
imagesList[String][]Directories under the package root, each holding a Dockerfile to build
publishesStringnoneThe service whose connection this node hands out itself. Needs an Access output too
featuresObjectall offSmall flags about what the node is
displayObjectnoneWhat to render on the node’s body after a firing
validateList[]Rules the compiler checks against the graph
portsFromConfigObjectnoneSays this node’s ports come from a list in its own config
typesMap{}Named types this node contributes to the project
firesWithMap{}For a trigger: the shape of the event it wakes with
claimsRouteObjectnoneWhich config fields hold the public address this node claims
accessAppsMap{}Public OAuth apps this node ships, by service
serviceObjectnoneThe connect recipe, on an access node. Go and read declaring a service

inputs

KeyTypeDefaultWhat it does
nameStringrequiredThe port name, unique among inputs
typetype stringrequiredWhat it takes
requiredBooleanfalseA firing with nothing here skips the node. Write it only when true
acceptsListboth["wire"] refuses a written value, ["literal"] refuses an arrow. A Bus or Generator port is wire-only whatever you say
widgetObjectfrom the typeThe editor control
defaultanynoneWhat the runtime supplies when nothing drives the input. Never written into source
labelStringthe nameThe field’s name on the form
placeholderStringnoneHint text in the empty field
descriptionStringnoneA sentence next to the field
requiresScopesList[String]nonePermissions this consumer needs on the wired connection. Access inputs only
requiresValuesList[String]noneStored values it needs. Access inputs only

outputs

KeyTypeDefaultWhat it does
nameStringrequiredThe port name
typetype stringrequiredWhat it emits
descriptionStringnoneA sentence next to the port
bakedBooleanfalseOn an infrastructure node: the value is worked out when the node’s infra is applied, and saved, so a run that reads nothing else from the node uses the saved value instead of running the node. For how, go and read baking

An output has no required. A firing that emits nothing on a port closes it, which is what downstream reads.

features

KeyTypeDefaultWhat it does
isTriggerBooleanfalseThis node starts executions from outside instead of running inside one. Weft gives it callsPerMinute, callsAtOnce, durable, recorded and keepRunsFor inputs (and outlivesCaller too when it sets liveConnection), so do not declare those
liveConnection"http" or "websocket"noneOn a trigger whose run answers a caller holding the connection open: "http" for a request waiting on its response (a route), "websocket" for a socket. It also gets callsPerMinutePerCaller
answersCaller"whole", "stream" or "end"noneThis node answers its run’s live caller. "whole": the response in one go (a Reply). "stream": the response head, then the body piece by piece (a Stream). "end": the end of the exchange (a Close). Set it on your own node too if it answers through the ctx, so a route’s “never answers its caller” warning counts it
calledFromOutsideBooleanfalseOn a trigger somebody outside calls whose call ends there (a form somebody submits). It also gets callsPerMinutePerCaller. A liveConnection trigger is called from outside already, so it never sets this; a trigger that picks its events up itself (a schedule, a feed, a provider’s push) has no caller and does not either
catchErrorsBooleanfalseSet it when the node reaches outside and a program may want to handle its failures. Weft gives it an error output, and when that output is wired the failure’s message goes there; unwired, the failure stops the run. The node needs no error handling of its own. Declaring its own error output is refused when the node loads. For the details, go and read letting the program handle a failure
pureBooleanfalseSet it when the node’s body does nothing outside its run that weft does not put on record first: no network, no connection, no ctx.run, no ctx.await_signal, no tag, no storage beyond the run’s own files. It may answer its caller (a durable run’s answer leaves only once its record is written), read stored files, store files for this run alone, hand its caller links to them, and use a bus to another node of the run. A durable run then starts its step without waiting for the database, and may run it again after a crash (for when, go and read surviving a restart). A pure node that reaches outside through the ctx anyway fails at that call, naming this flag; weft cannot see a body that opens a socket or a file on its own, so if you mark one of those pure, a durable run may do what it does twice after a crash. Text, JsonObject, Switch, Route and Reply are pure; HttpRequest and PostgresExecuteQuery are not, because each one reaches outside the run on its own
oneOfRequiredList[List[String]][]Each inner list is a group where at least one port must arrive, or the node skips
canAddInputPortsBooleanfalseSource may declare extra inputs on it
canAddOutputPortsBooleanfalseSource may declare extra outputs
optionalCustomInputsBooleanfalsePorts people add are optional rather than required
customInputTypetype stringnoneThe type every added input takes. Naming one variable makes them all one type
showDebugPreviewBooleanfalseRender the last output as JSON on the node body
liveEndpointStringnoneThe declared endpoint the dispatcher proxies /live to
castPorts{input, output}noneDeclares the node a checked cast between those two ports
hiddenBooleanfalseKeep it out of the picker and out of describe-nodes

display

KeyTypeWhat it does
kind"media" or "link"media renders by type: images inline, a real player for audio and video, a file card otherwise. link is always a file card with a download button
inputStringShow this input’s value
outputStringShow this output’s value

Exactly one of input and output, never both and never neither.

widget

Every widget, by its kind:

kindOther keysWhat it draws
textOne line of text. The default for String
textareaA wrapping box. The default for anything JSON-shaped
codelanguageA code box with highlighting. language is one of python, javascript, sql, json, and any other word fails to load. json also fits a structured input (JsonDict, a list), edited as JSON text; the others edit a String
numbermin, max, stepA number box. min and max are real rules the runtime enforces, and a whole-number step ("step": 1) means the input takes whole numbers: a written value is checked when the program compiles, a wired one when it arrives. A fractional step is only the arrow keys’ increment
checkboxA tick box. The default for Boolean
datetimeA date and time picker, stored as ISO 8601
selectoptions, free_textA dropdown. options cannot be empty, and they are a real rule: a written, wired or instance-provided value outside them is refused, and so is a default. If the list is only suggestions (model ids, voices, anything the provider adds to), set "free_text": true: the editor still offers the list, lets the user type anything else, and nothing is refused
multiselectoptions, free_textMultiple choice over a List[String], each item held to options the same way, unless free_text is set
text_listShort strings added one at a time. The default for List[String]
passwordA masked box
file_dropaccept, type, multipleA file picker that writes an @asset(...) reference
entry_listBuilds the config list this node’s ports come from. Only legal on the input portsFromConfig names
accessThe connection picker. Write just {"kind": "access"}: the service and whether it is optional are filled in from your service recipe
remote_selectaccess, sources, depends_on, free_textPick a resource on the connected service

Leave widget out and weft picks one from the type: a file type gets file_drop, Boolean gets checkbox, Number gets number, List[String] gets text_list, String gets text, and everything else gets textarea.

remote_select sources

kindKeysWhere the options come from
grantedfrom, label, valueThe connection row itself, recorded at sign-in. Costs no call
lista Lookup, plus requiresCalling the service and enumerating
pickerscript, code, grants, mime_typesThe provider’s own chooser, opened on a weft page
from_urlpatternThe person pastes a link and the first capture group is the id

A Lookup takes get (a URL with {query} in it), items (a dotted path to the array), label, value, optional page (cursor_param and cursor_path), and public when the call carries no credential at all.

validate

Each rule is { "when": <condition>, "then": <diagnostic> }.

The diagnostic takes message (with {id}, {port}, {field} and {custom_outputs} filled in, plus the three the conditions below name), level (structural, the default, or runtime), severity (error by default, or warning, info, hint), and optionally port or field to say what the finding is about.

A structural rule is checked every time the project is parsed or built. A runtime rule is only checked by weft validate and by the editor just before you Run, so a half-wired sketch still builds.

The conditions are a closed list:

kindKeysWhat it asks
input_satisfiedportDoes this input have a value, from a wire or written in
input_wiredportDoes it have a wire specifically
output_wiredportDoes anybody downstream read this output
input_source_typeport, equalsDo all wires into this port come from that node type
config_presentfieldIs this field set to anything non-null
config_nonemptyfieldIs it set and not blank or empty
config_equalsfield, equalsDoes it equal this exact value
config_in_setfield, valuesIs its string one of these
config_matchesfield, regexDoes its string match. False if absent, not a string, or the regex is broken
custom_outputs_declaredDid the source add outputs beyond the metadata’s own
run_reachesdirection, withDoes this node’s run hold a node with a feature. downstream: every run this node starts holds one. upstream: this node is in the run of one
downstream_ofwithIs a node with a feature wired upstream of this one, so it runs first. {with} in the message names those nodes
per_instanceDoes this node exist once per instance: marked @per_instance, with an @instance_filled field, or reading one of those (a group that receives a per-instance value on any port counts for everything reading its ports). {per_instance_reason} in the message says why, path included: “it reads ‘blender’”, or “it sits inside group ‘work’, which receives ‘blender’”
input_namesport, namesIs every name written on this input something of this program. The names are the String, each String of a list, or each key of an object. names says what they name: {"node": {}} is a node spelled the way the program writes it (bridge, one.bridge), narrowed with role (infra or trigger) and per_instance (true or false); {"field": {}} is a node’s field written node.field (send.model), narrowed with instance_filled (true or false). Nothing written is fine; a wired name is only known at run time, so it is not checked. {names} in the message lists the ones that fail
all / anyofEvery one, or at least one
notofThe opposite

with picks nodes by one feature they declare, never by type, so your own node counts the same as a catalog one: {"answersCaller": true} is any node that answers its caller, {"answersCaller": ["stream"]} only a Stream-like one, {"liveConnection": ["http"]} a trigger with an HTTP caller.

Two examples from the catalog. A route warns when nothing in its run answers its caller, and refuses to become per instance, since its public address is one for everybody:

{ "when": { "kind": "not", "of": { "kind": "run_reaches", "direction": "downstream", "with": { "answersCaller": true } } },
  "then": { "message": "Route '{id}' starts a run that never answers its caller...", "severity": "warning" } }
{ "when": { "kind": "per_instance" },
  "then": { "message": "Route '{id}' would exist once per instance, because {per_instance_reason}..." } }

A node that starts an instance’s copy of an infra node refuses a name that is no such node, and SetInstanceValues refuses a key that is no field an instance fills:

{ "when": { "kind": "not", "of": { "kind": "input_names", "port": "node", "names": { "node": { "role": "infra", "per_instance": true } } } },
  "then": { "message": "StartInstanceInfra '{id}' names {names} in `node`...", "port": "node" } }
{ "when": { "kind": "not", "of": { "kind": "input_names", "port": "values", "names": { "field": { "instance_filled": true } } } },
  "then": { "message": "SetInstanceValues '{id}' gives {names} in `values`...", "port": "values" } }

portsFromConfig

For a node whose ports come from a list the author writes, like a form’s fields or a switch’s cases.

KeyDefaultWhat it does
fieldrequiredThe config key holding the list. Must be a declared input
matchInputnoneThe input the entries are matched against, when only one entry wins
specsrequiredThe entry kinds you accept, and the ports each one adds

Each spec takes kind (required), keyField (default "key", the entry key holding the port name), label, render, fields, catchAll (at most one, and it goes last), addsInputs and addsOutputs.

A port template is { "nameTemplate": "...", "portType": "..." }, with {key} replaced by the entry’s key.

A spec field is { "key", "label", "required", "shape", "valueType", "widget" }, where shape is one of typed (needs valueType), value, valueList, number, element or regex. Every shape but typed needs matchInput set.

types

A flat map from a name to a type. Nominal and project-wide: any node’s ports can use a name any node declared. The same declaration in two files is fine and absorbs, so a package root can declare its shared types once.

"types": { "ChatMessage": "{ role: String, content: String }" }

firesWith

For a trigger. A flat map from field name to type, saying exactly what the event carries. A ? goes on the name, not the type:

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

It is exhaustive both ways. A payload missing a field you declared without ? is refused, and so is a payload carrying a field you never declared, at the top level and nested.

claimsRoute

pathField names the config field holding the route pattern, and the optional methodField names the one holding the HTTP method. Leave the method out, or leave it empty on the node in your program, and the node claims every method.

accessApps

Public OAuth applications your node ships, keyed by service. Each takes label, client_id, and any extra field the service’s recipe asks for.

A client_secret here is refused. Node metadata is source, and source never holds secrets. An app with a secret belongs in the apps file.

Package defaults

A package root can hold a partial metadata.json whose keys every member inherits. Members win, key by key, at the top level only, with no deep merge: a member’s types replaces the package’s rather than adding to it.

Three keys can never be defaults, because they are one node’s identity:

package-level metadata.json must not set `type`: it is one node's identity, not a package default

Three keys that no longer exist

If you are reading older node code, the loader names these rather than saying “unknown field”:

What you wroteWhat to do now
inputs[].exposureUse accepts. ["wire"] refuses written values, ["literal"] refuses arrows, and leaving it out allows both
outputs[].requiredRemove it. An output has no optionality
features.isOutputDefaultRemove it. Every node runs when it is reached, and weft run --target <node> narrows a run

A folder with no mod.rs

That is not an error. The node is discovered, reported as pending, and left out of the build. A program that never names it builds fine, and a program that does gets told which folder is waiting for its code. Writing the metadata first and the Rust second is a supported way to work.

The ctx

ctx is everything your node’s code can reach: its inputs, and every service weft provides. The runtime hands it to run, and to a trigger’s setup_trigger.

Every call here returns WeftResult, and your node never names a weft error type. Wrap an outside error with .node_err("what you were doing"), and fail on something you detected yourself with node_bail!.

Six rules that save you a debugging session

Calls on a connection go through ctx.open or ctx.client. That is what signs the request and records what it cost. ctx.http() is for everything else. A reqwest client you built yourself skips both.

ctx.await_signal is refused once your node has emitted anything, because a resume replays the body from the top and would emit again. It is also refused on a node with a Generator input, because a stream cannot be replayed.

ctx.run keys on call order, not on the name you gave it. The name is for reading logs. If your body can call it a different number of times on a replay, wrap the thing that varies.

put with no keep policy is run-scoped, and the file is swept shortly after the run ends. Right for scratch, wrong for anything a person will open later.

ctx.stop_tagged returns when the stop is queued, not when the other runs are gone. Do not write a node whose next step assumes they stopped.

ctx.register_signal is once per node per setup. A second call is a loud error rather than a trigger that quietly never fires.

Which firing am I

FieldTypeWhat it is
execution_idExecutionId (a UUID)This run’s id
project_idUuidThe project it belongs to
node_idStringThis node’s id in the graph
node_typeStringIts catalog type, such as ExecPython
node_labelOption<String>The title shown on the box, if it has one
framesLoopFramesWhich loop iterations this firing sits inside
instance()Option<&InstanceId>Which instance this run is for, when it is for one instance of the program (see programs with instances)

Reading inputs

ctx.inputs holds this firing’s inputs, whether they arrived on a wire, were written into the body, or came from a declared default. ctx.wake holds the event payload, and only on the trigger that actually fired. Both are a ValueBag with the same methods.

CallWhat you getIt fails when
get::<T>("name")A required valueIt is absent, or it does not fit T
opt::<T>("name")Option<T>; absent or null gives NoneA value is there and does not fit T
get_or::<T>("name", default)The value, or your defaultA value is there and does not fit T
list::<T>("name")Vec<T>; absent gives [], and one value gives a one-item listAny item does not fit T
access("name")The connection picked on an access inputNobody picked one and the service requires it
raw("name")Option<&Value>, the JSON as it arrivednever
object() / record()The whole bag as one recordOn a wake bag whose event was missing or not an object
nested("name")The object under name, as its own bagThe value is there and is not an object
iter()Every named valuenever
custom()Only the ports this node added in the program, not the type’s own settingsnever
declared()Only the type’s own declared settingsnever
in_order()Every input that arrived, in source orderOn a wake or nested bag
declares("name")Whether the node declares this input at allnever
for_holes(holes, what, spell, declare)Values matched to named holes in a text, such as $user_id in SQLA hole names no port, or a wired port the text never reads

Never write get(...).unwrap_or(...). It turns a real type error into your default and you will not find out. get_or is the same shape and fails honestly.

Emitting

CallWhat it doesIt fails when
pulse_downstream(output).awaitSends values onA port is mentioned twice, is not declared, gets a value its type does not allow, or is error on a node with catchErrors
yield_downstream(output).awaitThe same, but waits until the value was takenSame, plus a delivery that can never happen, and an empty output
close_port("port").awaitCloses one output. On a stream port this is the end of the streamThe port was already mentioned this firing, or is error on a node with catchErrors
set_max_buffered_items("port", n)Raises the un-taken cap on a stream port above 4096The port is not a stream output, or n is 0
fan_declared(&value)Builds an output by matching an object’s keys to your declared ports, skipping the rest. On a node with catchErrors it never fills error: a key called error in the data is datanever
data_outputs()Your declared ports, minus error on a node with catchErrors: the ones a node fills from data (a row’s columns, a response’s fields)never
output_type("port")?The resolved type of one outputThe node does not declare that output: a type error, so it fails the run even with error wired
declared_outputs() / declared_inputs()Every port this node declares in the program, with its typenever
is_output_wired("port")Whether anything downstream reads that output in this runnever

An ordinary port emits at most once per firing. Whatever you never mention is closed for you when the body returns. A Generator[T] port emits as often as you like until you close it.

NodeOutput is the payload you hand those calls:

CallWhat it does
NodeOutput::new()An empty one
.set("port", value)Sets one port
.set_serialized("port", &value)?Sets one port to any Serialize value (a struct, a list of them). Fails naming the port when the value has no JSON form
.extend_from_object(&value)Fans every top-level key onto a same-named port. Last write wins
NodeOutput::stored_file(stored)The four ports a stored file travels as: file, filename, mimeType, sizeBytes
.get("port")Reads back what you already set

Waiting, and surviving a restart

CallWhat it doesIt fails when
await_signal(kind).awaitParks this firing until the signal fires, releasing the worker. Returns the payloadThe body already emitted, or the node has a stream input, or the body’s call order changed between replays
register_signal(kind).awaitSets up a trigger that starts a new run on every fire. Called during setupCalled twice for one node in one setup
run("name", closure).awaitRuns the closure and writes down its result, or gives back what was written down last timeThe closure itself fails

ctx.run is how you stop a replay from doing an expensive thing twice. A body replays only after a wait. If the worker dies while the body is running, the step is not replayed: a durable run fails it, and a fast run ends cancelled (go and read surviving a restart).

Connections

CallWhat you getIt fails when
open(&access).awaitThe connection, leased for this firing, with its token refreshedIt needs reconnecting, the service does not match, or the door refuses
open_within(&access, window).awaitThe same, with a window you choose instead of the default 15 minutesSame
client(access).awaitStraight to the signed-in HTTP client. Takes None and gives you a plain oneSame, when it is Some
http()The shared plain HTTP client, no credentialsnever
publish_access(values).awaitPublishes a connection to something this node runs itself, such as a database it provisionedA field the service does not declare, or a required one missing
published_access().awaitWhat this node published before, or NoneThe broker cannot answer
endpoint("name").awaitA handle on one of this node’s declared infra endpoints, once something answers thereThe endpoint is not declared, or the infra is not running

On an opened connection: client(), credential(), value(name), identity(), owner(). On an endpoint handle: url(), host_and_port(), and call(method, path, body).

Sharing something between runs

If your node builds something on every run that is slow to build (a pool of database connections, a process to hand work to), let the worker’s runs share one instead:

let pool = ctx.shared(&access, |opened| async move { Pool::new(&opened) }).await?;

The closure is async and its result is a WeftResult of the thing (Pool here is your own type, whose new returns a WeftResult<Pool>).

The first argument is the key weft keeps the thing under. If you pass a connection (&access), the closure gets the opened connection, and once one of its values changes (a new password), the next run that asks gets your thing built again from the new values. To notice the change, weft opens the connection every time a run calls ctx.shared. For a connection a person made or an infra node published, the worker answers that open from memory, so a reused thing costs no extra call. A connection set to Use ours (weft’s own key) costs one network call to weft each time.

If you pass a name of your own instead, the closure gets ():

let python = ctx.shared("exec_python", |()| async { Ok(Interpreters::new()) }).await?;

On one worker, every node that asks under the same name for the same Rust type gets the same thing, so pick a name that is your node’s own, the way ExecPython uses "exec_python". A worker serves one project, so nothing another project builds can collide with yours.

ctx.shared hands back a weft::shared::SharedHandle<T>, which derefs to the thing. If two runs ask at the same moment, it is built only once. A build that fails keeps nothing, so the next run tries again.

If the thing can only serve so many runs at once, say so with with_limit:

let pool = ctx.shared(&access, |opened| async move { Pool::new(&opened) }).with_limit(100).await?;

Then at most that many handles to it exist at once on one worker, and the next run that calls ctx.shared waits until one is dropped. A run that asks again while it still holds a handle can end up waiting on itself once the limit is reached, so keep one handle per run. Only the limit of the first call that asks counts, until weft lets the thing go (after it goes idle, or after its connection changes), so pass the same limit everywhere you call it. The limit counts the handles to one thing on one worker: two workers can go past it between them, and so can the old thing and the new one for a moment after a connection’s values change, while runs still hold handles to the old one. The Postgres nodes that query through the shared pool ask for 100, Postgres’s own default connection limit.

If a task you spawn needs the thing after your run returns (handing a connection back to its pool later), move the handle into the task: it keeps its place under with_limit until the task drops it, which is what the Postgres nodes do. If your task should not count against the limit, take handle.arc() instead: the Arc keeps the value alive but holds no place under with_limit, and weft can let go of the thing while you hold it, so the next run may build a new one beside yours.

By default, weft lets go of a shared thing about five minutes after the last run asked for it or dropped its handle, once no handle is left. Your thing’s Drop runs once weft has let go of it and the last handle and Arc are gone, which is when a pool closes its connections. When the worker stops, everything goes with the process.

If you want a quiet stretch not to cost a rebuild, raise the five minutes with weft workers set --shared-idle-seconds <n> (or lower it to let go sooner). It is one setting for the whole project, covering every shared thing on its workers. The change applies from the next run: weft starts workers with the new value (a new revision on Cloud Run), and the old ones finish what they are running and stop.

Storage

ctx.storage(scope) gives you a handle. The scope decides where new files go and what list sees; get, delete, keep and presign act on whatever scope the key itself belongs to, so a later node can read a file without knowing where it came from.

ScopeWhere it lives, and how long
Execution (the default)This run only, swept after it ends unless you keep it
ProjectOutlives runs, gone on weft clean or weft rm, or when a lifetime you gave it runs out
Shared { name }Shared across projects that name the same space, until removed or its lifetime runs out
AssetThe project’s published assets. Readable, and the worker refuses writes
CallWhat it does
identified(id)Names what the next put is a copy of, so storing it twice stores it once
put(bytes, mime, filename, keep)Stores bytes and gives back the stored-file value
put_stream(stream, mime, filename, keep)The same without holding the file in memory
put_response(resp, what, mime, filename, keep)Streams an HTTP response you already made into storage
put_from_url(url, filename, keep)Fetches a URL straight into storage
copy(&file, keep)Copies a file into this handle’s scope, leaving the original
edit(&file, |old| ...)Changes the file’s content in place: reads it, runs your function on the bytes, writes back what it returns. Another write of the same file is never lost (it reads again and reruns the function), so your function must use nothing but the bytes it is given. Same key, name, type and lifetime, new size and version
replace(&file, bytes)Overwrites the file’s content in place, whatever it holds now
get(&file)The file’s bytes, as a stream
get_range(&file, range)Part of the file
get_bytes(&file)The whole file in memory. Only for small ones
delete(&file)Deletes it. Stored files only, not URL-backed ones
list()Everything under this scope
keep(&file, ttl)Sets how long the file lives from now on, and saves a run-scoped one from the sweep (that part cannot be undone)
presign(&file, ttl)A temporary link. None means the default, about 15 minutes
public_link(&file, ttl)A link the open internet can fetch, or None if this install serves none
caller_link(&file, ttl)A link the caller of this run can fetch, on the address its request came in on
externalize(&value, &ty, policy)Turns every file inside a typed value into a link or inline bytes, for handing out
internalize(&value, &ty, keep)The reverse: pulls URLs and inline data into stored files

keep takes KeepTtl::Default (30 days, pushed back each time the file is read), Secs { secs }, or Never, in every scope but Asset. For what it means in each scope, go and read how long a file lives.

What comes out of externalize is no longer a value of that type, because the links expire. Hand it to whoever asked and do not store it.

Streams and buses

CallWhat it doesIt fails when
create_bus(opts)Makes a bus and gives you a handle and a marker to emitThe options do not make sense
bus(&marker)Turns a marker value back into a live handleThe bus is gone, or that is not a marker
open_bus("port", opts, "name").awaitThe whole producer move: make it, emit the marker, register your name. Closes on dropEmitting or registering fails
join_bus("port", "name")The consumer twin, also closing on dropThe input is not a live bus
bus_from_input("port")A handle that does not close the bus. For an observerSame

Read a stream input like any other value: ctx.inputs.get::<Generator<Row>>("rows")?.

CallWhat it does
next().awaitThe next item, waiting. None once, at a clean end
try_next()Item, Empty or Finished, without waiting
drain().awaitEverything, waiting for the end. A failed stream errors instead of returning a partial list
end()Whether the producer finished or failed, or None while it is open

Live callers

CallWhat you get
is_api_call() / is_websocket()Whether a caller of that kind is attached
caller_data_type()Whether this connection speaks bytes or JSON
caller()The caller as a handle, or None
http_caller().awaitThe HTTP caller, attached and connected, or a loud error
ws_caller().awaitThe same for a WebSocket
live_caller().awaitWhichever one is connected
caller_request()What the caller sent to open the exchange, without waiting for the connection

Logging, tags, cancelling

CallWhat it doesIt fails when
log(level, message).awaitWrites a line into the run’s log. Trace, Debug, Info, Warn, ErrorThe write fails
tag_execution(["user_7"]).awaitTags this run. Additive and safe to repeat. Any string works: one that is not already a tag is turned into one (see below)The list is empty, or a tag is empty
stop_tagged("user_7", StopSelf::Keep).awaitQueues a stop for every live run of this project carrying that tag, turned into a tag the same wayThe tag is empty
is_cancelled()Whether this run was cancelled. Cheap enough to pollnever
cancellation()The flag itself, to select! against your own worknever

StopSelf::Keep only reaches runs that took the tag before this one did, so two runs racing to stop each other leave the later one alive. StopSelf::Include ends this run too.

Your project beyond this run

The runtime answers each of these calls on behalf of this run and writes the answer into the run’s journal, so a replay reads the answer back instead of asking again. Most of them take an optional .instance(id), which picks that instance’s copy instead of the shared one; values(), connections() and tokens().instance(..) always name an instance.

CallWhat it does
infra("bridge").start().awaitBrings the shared copy up. Returns once it runs, parking the run between looks; fails with the reason if it does not come up, or if somebody stops it while this waits
infra("bridge").stop(spec, stop_self).awaitScales the copy down, keeping its disk
infra("bridge").terminate(spec, stop_self).awaitDeletes the copy and its disks, except the ones listed in keepOnTerminate
infra("bridge").instance(id).wipe(spec, stop_self).awaitDeletes the instance’s copy and every one of its disks, the kept ones too, even if the copy was already terminated
infra("bridge").status().awaitThe copy’s state, None when there is none. A start or stop on its way reads provisioning or stopping at once, the same answer weft status gives
infra("bridge").copies().awaitEvery copy: the shared one and each instance’s
trigger("receive").activate().awaitTurns one trigger on
triggers().deactivate(spec, stop_self).awaitTurns off every trigger of the program (or of one instance, with .instance(id)); if you want only some, name them with .only([..])
values().instance(id).get().awaitThe instance’s values for the program’s @instance_filled fields, by step and field
values().instance(id).set(step, field, value).clear(step, field).apply().awaitGives and clears values in one change, each checked against its node’s rules; the instance’s live triggers reading one are set up again, and their names come back
values().instance(id).forget().awaitForgets every value the instance was given
connections().instance(id).list() / forget()Lists an instance’s connections, or forgets all of them (the values naming them go too)
instances().list().awaitEvery instance weft holds anything for, one entry each: how many values, connections and live tokens, its infra copies, and its triggers, each with the events waiting on a field not yet filled and why
costs().instance(id).service(s).since(t).list().awaitThe cost records, each saying whose credential paid, narrowed by instance, node, service, run, paid_by or since (unix seconds)
runs().instance(id).status(s).older_than(d).clean(running, stop_self).awaitDeletes runs; runs still going follow running
runs()...list(limit).await / .count().awaitThe newest limit matching runs (1 to 200) with how many match in all, or just the count; same filters as clean
tokens().mint_for_instance(id, expires_in).awaitAn instance token, value shown once
tokens().instance(id).revoke().awaitRevokes an instance’s tokens

For what spec and stop_self decide, go and read taking something down from a run.

What is not here

ContextHandle, the trait underneath, is the seam the runtime implements. Your node only ever sees the ExecutionContext wrappers on this page.

Reading inputs, emitting outputs

What the methods are is on the ctx. This page is what happens around them: how a value gets into your bag, and what the runtime does with what you send.

Where an input’s value comes from

Wired data and design-time settings live in one bag, under the port’s name. Three things can fill a port, in this order:

RankSource
1A value that arrived on a wire
2A value written into the node’s body, if nothing arrived
3The default from the node’s metadata, if the port is still empty

Four rules around that, each of which exists to stop a quiet wrong answer.

A closure kills the default. If a wire arrived closed, meaning upstream produced nothing, the default does not fill in. A closure means something did not happen, and substituting a default would hide that.

A delivered null is a value only where the type allows it. On a port whose type admits Null, null is data. Anywhere else it reads as nothing arrived, and the default fills.

A wrong type fails the firing, required or not. It is never quietly dropped so the default can take over.

A number outside its declared range fails too. A poll interval of 0 on a port with a minimum never quietly becomes 30.

Two ports get special handling before your code sees them. A connection input arrives as a full Access value carrying the service, rather than the id the editor stored. And the gate ports are stripped out entirely, so your node never finds a _should_flow it did not declare.

A trigger is different again: on a real firing, its ports replay the values that were frozen when the trigger was set up, and written literals fill whatever is left.

Emitting

ctx.pulse_downstream is the only way out.

Once per port, per firing. Emitting or closing the same port twice is an error naming it:

node 'reply' touched port 'text' twice in one firing. Each output port can be
emitted or closed AT MOST ONCE per firing

Several calls are fine if they touch different ports. That is how you release a bus marker early and a done flag at the end. If any port in one call collides, nothing in that call is recorded, so a failed call is a clean no-op rather than half an emission.

A stream port is the exception. A Generator[T] output takes as many emissions as you like, one per item, until you close it. After the close, nothing can follow.

Anything you never mention is closed for you when the body returns. That closure is the signal everything downstream is waiting for. A node that decides it has nothing to say does not need to do anything special: it just returns, and the branch behind it skips.

A node that fails closes whatever it had not already sent. What it did send stays sent. weft cannot unsend a message your node already put on Slack.

An undeclared port is caught first, before the once-only rule, so a typo reads as the real problem:

node 'reply' tried to emit on undeclared output port 'txet'. Declare it in
metadata.json's outputs list, or correct the port name in the node body.

A value over 100 KB fails the call, naming the port. The check is on what you emit, so a node one hop later cannot trim it for you: bound it yourself (a cap input, a LIMIT), or put bytes in storage and send the marker.

Waiting for the value to be taken

pulse_downstream sends and carries on. yield_downstream waits until the value was actually taken, which is what you want when you are producing faster than the consumer reads.

It fails rather than waiting forever when delivery becomes impossible, for instance because the consumer skipped or the run finished. An unwired port counts as delivered immediately.

Building the output

NodeOutput::new()
    .set("count", words.len() as f64)
    .set("longest", longest)

set takes anything that turns into JSON. If you already have a Value, it passes through rather than being wrapped again.

For a dynamic object whose keys should land on same-named ports, ctx.fan_declared(&value) matches them against the ports your node actually declares and skips the rest. That matters after a paid call: an extra key in a provider’s response should not fail your node when you already spent the money.

NodeOutput::stored_file(stored) fills the four ports a stored file travels as: file, filename, mimeType and sizeBytes.

Errors

You wantWrite
To wrap somebody else’s errorresult.node_err("reading the reply")?
To say what was missing when an Option was Noneoption.node_err("the response had no body")?
To fail on something you worked outweft::node_bail!("pick a channel or a user")
The same, as an expressionnode_error(format!("..."))

Those, plus ? on anything the ctx returns, cover every failure except a mistake in the program itself, which you return as Err(WeftError::Input(..)) (see Letting the program handle a failure).

Write the message as an instruction to whoever is building the program, because that is who reads it. “pick a destination: a channel or a user” beats “invalid configuration”.

Letting the program handle a failure

If your node reaches outside, it can fail for reasons the program might want to deal with: a model refusing a prompt, a host that does not answer, a database rejecting a row. If you want a program to be able to handle those, set this in the node’s metadata:

"features": { "catchErrors": true }

weft then gives the node an error output, and your body just returns its errors with ? as usual. If the program wires error, the failure’s message comes out there, the step counts as done, and every output the body had not sent yet closes. If nobody wired it, the failure fails the run.

In a durable run, a step that failed because its worker went away goes to error too (go and read surviving a restart).

weft owns error: your node cannot declare it, and a body that emits on it fails. If your node only shapes values (a cast, a switch, a template), leave the flag off: it has nothing outside to fail on.

Only failures of the call itself are caught: what node_err, node_bail! and outside errors produce. A bad setting, a bad input or a bad type always fails the run, wired or not, because the fix for those is in the program. The ctx sorts its own refusals the same way: ctx.output_type on a port your node does not declare fails the run, so just use ? on it. A cancel, or a step pausing to wait for an answer, never becomes an error value.

So when your node spots a mistake in the program itself (SQL it cannot run, a value of the wrong shape, an empty field it needs), fail with Err(WeftError::Input(..)). If a program could sensibly retry or route around the failure while it runs, use node_bail! instead. If your node talks to a server, decide the same way for each kind of refusal: the Postgres nodes fail the run on a syntax error or a missing table, and put a broken constraint on error.

In a test, rig.wire_output("error") wires error the way a program would. Without it the rig wires nothing, so the same node fails loudly.

Custom types

Give a shape a name and every node can use it.

"types": {
  "ChatMessage": "{ role: String, content: String }",
  "ChatHistory": "List[ChatMessage]"
}

Then in any node’s ports, including other people’s:

"inputs": [ { "name": "messages", "type": "ChatHistory", "required": true } ]

They are global, and compared by name

Every metadata.json in the project contributes its types to one table before anything else is parsed, so a name declared by one package is usable by every node.

The comparison is by name, not shape. CustomerId declared as String is a different type from String, and wiring one into the other is a mismatch. That is the point: a customer id should not land in a port that wanted any old string.

A named type flows into its shape for free, because the name falls away when it is not needed. Going the other way, from a plain shape to a named one, needs a Cast, which checks the value really has that shape.

What travels

The name carries its shape with it. A port typed ChatMessage stores as ChatMessage={ role: String, content: String }, so anything reading a saved project or a journal row understands it with no table to consult, and a run that started yesterday keeps the shape it started with even if you edit the declaration today.

Where to put them

A package root’s metadata.json can declare the types its nodes share, once, and every node in it inherits them.

Two packages declaring the same name with the same shape is fine and absorbs. Two declaring it with different shapes names both, and a port that restates a declared name with a different shape is the named-type-conflict error. A named type has one body everywhere.

Nothing shadows. A name already visible cannot be redeclared, not even to the same thing.

Cast

Any node can declare itself a cast:

"features": { "castPorts": { "input": "value", "output": "value" } }

The compiler reads the declaration, never the node’s name, so this is a thing any node can be rather than one built-in.

At compile time, the resolved pair of types has to be a conversion that exists:

FromInto
StringA number, a boolean, or any object shape, parsed as JSON and then checked
Anything that is not a file or a live handleString
NumberBoolean, and back
Any object shapeA record or a named type, checked against the declared shape

Anything else is refused at compile time:

no cast from JsonDict into Number; a cast parses text, stringifies data, or
validates an object shape against a declared type

At run time, the check names the field that was wrong, like messages[2].role: expected String, got Number, rather than failing with a shrug.

Stored files, buses and connections do not turn into text, so those casts do not exist.

Showing something in the graph

Every value on every wire is already in the inspector. You never hand-deliver information to whoever is watching.

Two things go further, and they share a word, so keep them apart.

A file, rendered on the node

Name one port in your metadata and the editor renders its value on the box after each firing.

"display": { "kind": "media", "output": "image" }
kindWhat you get
mediaThe file rendered by its own type: a picture inline, a real player for audio and video, and the file card for anything that will not play
linkAlways the file card, with its details and a download button

Exactly one of input and output, naming the port. The value has to be a file.

Use link for something somebody downloads and media for something they look at or listen to.

A live panel

This is the other one, and it is a different thing: a panel that updates while something runs, rather than a value from a firing.

Two kinds of thing produce one, and only one of them is your job.

An infrastructure node’s container. Your container serves /live, and you opt in by naming the endpoint:

"features": { "liveEndpoint": "api" }

Declaring it is the opt-in. There is no separate flag. The dispatcher proxies the panel to your endpoint, and a node that names none answers 404.

A trigger’s signal kind. Written by weft, inside the listener, not by you. A node that declares a trigger writes no display code at all, and those panels are read-only: the listener refuses to serve one carrying a button rather than show a button that cannot work.

What a panel holds

A list of items, each with a label, and at most one action.

typedataDrawn as
texta stringA box you can copy from
imagea string an <img src> takes, so a URL or a data: URIThe picture
progressa number from 0 to 1A bar
secreta stringMasked behind dots until somebody clicks the eye. Copy gives the real value either way, and weft infra env writes it into an env file without anyone seeing it

An answer that is not a list of items comes back to the reader as an error naming what you sent, rather than an empty panel. One item it cannot read becomes a line saying so, in its place, and the rest of the panel still draws.

A number outside 0 to 1 on a progress bar is shown as given rather than clamped, because clamping would hide the bug in whatever produced it.

Who can see it

Nobody, by default. A signal token reads no display at all unless it was minted with --display <node> naming that node, or --displays for all of them.

That is on purpose. A panel can be a credential: a bridge showing a QR code to scan, a database showing the password it just minted. So the scope grants nothing until you say which node.

Storage

A value on a wire is capped at 100 KB. Anything bigger goes into storage, and what travels the wire is a small marker saying where it is.

let storage = ctx.storage(StorageScope::Execution);
let stored = storage.put(bytes, "image/png", "chart.png", None).await?;
ctx.pulse_downstream(NodeOutput::stored_file(stored)).await

The five scopes

The scope decides where new files go, and how long they live.

ScopeLivesGone when
Execution (the default)This runFive minutes after the run ends, so its output is still downloadable, unless you kept it
ProjectAcross runs of this projectweft clean or weft rm, or once its lifetime runs out if you gave it one
Shared { name }Across projects that name the same spaceExplicit removal, or once its lifetime runs out if you gave it one
AssetThe project’s @asset copiesReadable by your node, and the worker refuses writes to it
Instance { of }One instance’s space in this project (StorageScope::instance() for the run’s own instance, instance_of(id) for any)Until removed. For what removes them and who reaches them, go and read an instance’s files

The scope governs writes and lists. Reading, deleting, keeping and signing act on whatever scope the key itself belongs to, so a later node can get a file without knowing where it came from. copy is the one that does both, reading from the key’s scope and writing into yours, which is how a file crosses.

How long a file lives

An execution-scoped file is swept shortly after the run ends. If you want it to outlive the run (anything a person will open later), give it a lifetime:

storage.put(bytes, "image/png", "chart.png", Some(KeepTtl::Default)).await?;
KeepTtlMeaning
Default30 days
Secs { secs }That long
NeverNo expiry. Only weft files rm or weft clean removes it

The lifetime counts from the last time something touched the file: every read, link or replace pushes the expiry back, so a file something still uses does not vanish underneath it.

Project, shared and instance files take a lifetime too. There, None means the file lives until somebody deletes it. A cache of fetched pages, say:

ctx.storage(StorageScope::Project)
    .put(page, "text/html", "page.html", Some(KeepTtl::Secs { secs: 7 * 24 * 3600 }))
    .await?;

For a file that already exists, keep(&file, ttl) sets its lifetime from now on. On an execution file it also marks the file to outlive its run, and that mark cannot be taken back. On the other scopes keep(&file, KeepTtl::Never) takes a lifetime away again. An asset lives as long as a version of the project still names it (once none does, it expires on its own), so you cannot give one a lifetime.

Storing

CallUse it when
put(bytes, mime, filename, keep)You have the bytes
put_stream(stream, mime, filename, keep)You do not want the whole file in memory
put_response(resp, what, mime, filename, keep)You already made an authenticated request and want its body
put_from_url(url, filename, keep)A plain URL, fetched straight in
copy(&file, keep)A file that already exists, into this scope

Every one of these makes a new file under a fresh key, so two puts never collide, whatever their filenames.

Changing a file in place

If your node keeps something that grows with use (a conversation, a log, a document it keeps adding to), keep it in a stored file and change that file. A port carries at most 100 KB, and a growing value will pass that. Have the node take the file in and pass the file on, and do not also offer the same content as a plain value, so a program has only one thing to wire.

To change what the file holds, use edit. It reads the content, hands it to your function, and writes back what you return:

let updated = storage.edit(&file, |old| {
    let mut lines = old.to_vec();
    lines.extend_from_slice(b"one more line\n");
    Ok(lines)
}).await?;

If two writers change the same file at once (two runs on one project file, the iterations of a parallel loop), neither loses the other’s change. If the file changed between the read and the write, edit reads it again and runs your function again on the new content. Because it can run more than once, the function must use only the bytes it is given: no counter it bumps, no call out, no other read. Returning the bytes unchanged writes nothing.

The file keeps its key, so every reference already handed out now reads the new content. It also keeps its scope, name, type and lifetime. A reader sees either the old bytes or the new ones, never a mix. What comes back is the file’s value with its new sizeBytes and version.

Every edit is recorded on the run, and the node’s panel in the graph lists it under “Files edited”: the file, the versions it moved between (v4 → v5), and the lines that changed (a diff longer than 100 KB is cut short at a line boundary). A file that is not text shows its old and new size.

If you want to overwrite the file whatever it holds now, without reading it, use replace(&file, bytes). It shows under “Files edited” too, with the new size instead of the changed lines.

An asset cannot be edited or replaced, and neither can a file value that points at a URL.

Versions

Every stored file value carries a version: 1 when the file is made, going up every time the content is written. A key always names the same file, so a key plus a version always means the same bytes.

If you seed a run that would reuse a step whose file has changed since, the run is refused; for what to do then, go and read when a reused step’s file changed.

Storing the same thing twice

let storage = ctx.storage(StorageScope::Project).identified("slack:F123456");

identified names what the file is a copy of. Put it twice and it stores once, and with put_from_url a source you already have costs no request at all.

Name the source, like <service>:<id>, not the content.

Reading

CallGives you
get(&file)The bytes, as a stream
get_range(&file, range)Part of them
get_bytes(&file)The whole thing in memory. Small files only
list()Everything in this scope
delete(&file)Gone. Stored files only, not URL-backed ones

Handing a file out

CallThe link reaches
presign(&file, ttl)Whoever you give it to, for about 15 minutes by default
public_link(&file, ttl)The open internet, or None when this install serves no public address
caller_link(&file, ttl)The caller of this run, on the address its request came in on. This is what a route’s answer carries in place of a file
external_url(&file)An outside service you are sending the file to: the public link when there is one, else a data: URL carrying the bytes
external_file(&file)The same, plus the file’s mime type and filename, for an API that wants them beside the link

public_link returning None is not a failure. It means the store is private and nothing is relaying it. If you are handing a file to a provider’s API (an image to edit, a video to dub), call external_url instead of writing that fallback yourself.

If your install answers on more than one address (the loopback port, a tunnel, a domain), caller_link builds the link on the one the caller used, so a browser on http://127.0.0.1:14111 gets a loopback link even while the tunnel is open. If no request started the run (weft run --fire), the link uses the install’s internet address, or its own address when it has none.

Whole values full of files

A chat history with three images in it is a typed value with three file markers buried inside it. Two calls handle that.

externalize(&value, &ty, policy) walks every file slot the type names, at any depth, and turns each into a link or inline bytes depending on what the consumer takes. A provider that accepts image URLs but only inline audio gets exactly that.

internalize(&value, &ty, keep) is the reverse: it takes a value full of data: URLs and external links and stores them, giving you back something you can emit and that will still work tomorrow.

The rule that makes it safe: the link never leaves your node, and the marker never leaves weft. Anything you emit, park on, or memoize is stripped back to the stored form for you. What externalize gives you is no longer a value of that type, because presigned links expire, so hand it to whoever asked and do not store it.

What a stored file looks like

It is a marker naming its kind, and the kind comes from the mime type when it was stored.

Weft typeFor
Imageimage/*
Videovideo/*
Audioaudio/*
BlobEverything else: a PDF, a zip

Inside is the key, the mime type, the size and the filename. No URL, which is deliberate: a link expires and a marker does not, so the marker is what goes on wires and into the journal.

The marker is a one-key object, and the key names the kind: __weft_image__, __weft_video__, __weft_audio__ or __weft_blob__. A stored PNG looks like this on a wire:

{
  "__weft_image__": {
    "key": "acme/project/3f2a9c1e-7b4d-4e0a-9c55-2d1f0e8a6b17/5b0c2e44-1a9f-4c3e-8d2b-6f7a9e0c1d23",
    "mimeType": "image/png",
    "sizeBytes": 48213,
    "filename": "chart.png"
  }
}

File is shorthand for all four and Media is shorthand for the first three. Both are fine on a port. Neither can be the type on an @asset, because a value carries exactly one marker and those leave it open.

NodeOutput::stored_file(stored) fills the four ports a file travels as at once: file, filename, mimeType and sizeBytes.

Surviving a restart

A node can park for a week waiting for somebody to answer, and cost nothing while it waits. The trick is that the worker goes away and a fresh one picks the run back up.

Which means your body runs again from the top.

Read that twice, because everything on this page follows from it. When the answer arrives, a new worker replays your function from line one. It does not resume inside your await. It starts over.

What is remembered and what is not

On a replay
ctx.await_signal that was answeredReturns the answer straight away, without waiting
ctx.await_signal that was given upFails the same way again, without waiting
ctx.run("name", ...) that finishedReturns what it returned last time, without running the closure
Anything else in your bodyRuns again
Anything you emittedRuns again, which is why emitting before an await is refused

So the shape is: do the work, wrap anything expensive or irreversible in ctx.run, wait, then emit.

let quote = ctx.run("fetch quote", || async {
    let body = ctx.http().get(url).send().await.node_err("fetching the quote")?;
    Ok(json!(body.text().await.node_err("reading the quote")?))
}).await?;

let answer = ctx.await_signal(Form::approval("Send this?")).await?;

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

Without ctx.run, that fetch happens again every time somebody takes a day to answer.

Waiting on a job a service runs

If your node starts something slow on a provider (a video render, a dubbing job, a site crawl) and the provider hands back a status address, do not loop and sleep in your body: that holds a worker for the whole job. Submit the job inside ctx.run, then wait on the status address with PollEndpoint:

use weft::signal::{PollEndpoint, Predicate};

let submitted = ctx.run("submit", || async {
    post_json(&http, &submit_url, &payload, "submitting the render").await
}).await?;
let id = submitted["request_id"].as_str().node_err("the submit answered no request_id")?;

let status = ctx.await_signal(PollEndpoint {
    url: format!("{API}/requests/{id}/status"),
    interval_secs: 5,
    access: Some(weft::primitive::AccessRef::from(&account)),
    filters: vec![
        Predicate::neq("status", "IN_QUEUE"),
        Predicate::neq("status", "IN_PROGRESS"),
    ],
    ..Default::default()
}).await?;

The run parks and the worker goes away. Weft checks the address once straight away (a job that is already done resumes at once), then every interval_secs, and an interval under 5 seconds is refused. The first answer that passes every filter is what await_signal returns, and the checking stops. With no filters, the first successful answer is the one. An error status or a failed request is not an answer: it is tried again at the next check and shown on the node. access signs each check with that connection.

If the finished answer is heavy (a crawl’s status that embeds its first page of results), list the fields your node reads next in carry, like carry: vec!["status".into(), "error".into()]. The filters still read the whole answer, but only those fields are handed back, so megabytes of data never sit in the run’s history; read the rest yourself once the wait is over.

When that answer comes, your body replays from the top, so the submit has to be inside ctx.run. Without it the replay would start a second job, and pay for it.

Write the filters as “not one of the in-flight statuses” rather than “one of the finished ones”, the way the example does. A status you did not expect then ends the wait, and your code can fail on it, instead of waiting for ever.

When the run cannot pause

Some runs cannot let go of their worker: a route’s run while its caller is on the line (and the route does not outlive it), an unrecorded run, a run with a bus open. There, ctx.await_signal does not suspend. It holds in your call, on the same worker, and returns the answer when it comes, without replaying anything. If the run stays quiet for its trigger’s holdSecs, the call fails with WeftError::WaitGaveUp, saying why. That is an outcome of your step like any other: if your node can do something sensible without the answer, match it and carry on.

let approved = match ctx.await_signal(Form::approval("Send this?")).await {
    Ok(answer) => answer,
    Err(WeftError::WaitGaveUp(_)) => json!(false),
    Err(other) => return Err(other),
};

For what holds and when the clock runs, go and read when a run cannot pause. To test it, rig.signal_given_up() makes the next wait fail this way.

Two things weft refuses

Emitting, then awaiting. A replay would emit again, and the value already went downstream.

node 'review' called await_signal after emitting or closing an output port; a
node that touches a port then durably suspends would touch it again on replay.
Emit and close after all awaits, or (for a co-alive node) stay warm with
bus.recv() instead of await_signal.

Awaiting in a node that reads a stream. A replay cannot re-pull items that were already pulled.

node 'grade' has a Generator input and called await_signal; a stream consumer's
body cannot durably suspend. Do the waiting upstream or downstream of the
stream consumer.

ctx.run is keyed on order, not on the name

The name is for reading logs. What the runtime matches on is which call this was, counting every ctx.run and every ctx.await_signal in your body.

So two ctx.run calls with the same name in a stable order are fine, and two with perfect names in an order that changes between replays are not:

ctx.run('fetch quote') call_index mismatch (counter=2, journal=1). This means
the node body's call order changed between replays. Wrap any non-deterministic
logic in ctx.run.

If your body can take a different path on a replay, whatever decides that path goes inside a ctx.run so the decision is remembered rather than remade.

When the worker dies mid-step

What happens depends on the trigger’s durable setting. For what that setting does, go and read how a run is kept.

A fast run (the default) ends cancelled with its worker, and is not run again (go and read what happens when something dies).

A durable run carries on in another worker. Each step’s start (except a pure node’s, below) is on record before its body runs, so the next worker knows exactly which steps were running when the first one went away. weft cannot tell how much of such a step’s work already happened, so the next worker fails it and says why:

the worker running 'billing' went away while it was running; it was not run
again, because it may have partly happened. Re-run from here once you have
checked what it did.

A step that was reading a stream says went away while it was reading a stream instead: the items it had read are gone from the stream, so the message tells you to run the whole run again to start the stream over.

If you want to run that step again once you have checked, weft run --from <node>=<json> starts a run there with the inputs you hand it. If the node sets catchErrors and you wired its error output, this failure goes there like any other, and that branch carries on; otherwise the run fails.

If the step that was running belongs to a pure node that does not read a stream, and had not passed a value on yet, the next worker runs it again instead of failing it. A pure node does nothing outside the run (for what that means, go and read the pure flag in metadata.json), so running it twice changes nothing. One that had passed a value on is failed like any step, because running it again would pass that value a second time.

A run parked whole on ctx.await_signal survives its worker even when it is fast, unless a caller is still on the line and its trigger leaves outlivesCaller off, or a bus between its nodes is open (for why, go and read how a run is kept). When its answer comes, the waiting step replays from the top, and its ctx.run calls give back their saved results. A fast run with one branch waiting while another still ran ends cancelled like any other fast run.

In a durable run, if a step emitted a value before the crash and the step reading it had not started yet, that value is still delivered and the reading step runs.

When ctx.run cannot save its result

ctx.run writes its result down after the closure finishes. If that write fails, the action has happened but nothing recorded it, and the step fails with:

could not save result of 'charge the card' for node 'billing': <error>. The
action may already have happened; inspect this run before repeating it.

Two things are deliberately not wrapped, because they are already safe to repeat on a replay: ctx.tag_execution keeps its place in the order, and ctx.stop_tagged finds its targets already gone.

What a resume actually does

The new worker fetches your program by hash, reads the journal to see where the run had got to, and carries on from there: a step that was running is failed, a step that was waiting replays, and every step whose inputs are ready starts.

When the worker stays

A run holding a live caller, or a bus with nodes still talking on it, keeps its worker up. Parking there is an ordinary await in the same process, not a death and a rebuild, so nothing replays.

That is why the refusal above suggests a bus for a node that has to stay warm: if two nodes are alive together and talking, they do not need durability between them.

Writing a trigger

A trigger starts runs from outside. It has two bodies instead of one.

#[async_trait]
impl Node for CronNode {
    async fn setup_trigger(&self, ctx: ExecutionContext) -> WeftResult<()> {
        let expression: String = ctx.inputs.get("cron")?;
        let timezone: String = ctx.inputs.get("timezone")?;
        ctx.register_signal(Timer { spec: TimerSpec::Cron { expression, timezone } }).await
    }

    async fn run(&self, ctx: ExecutionContext) -> WeftResult<()> {
        ctx.pulse_downstream(ctx.fan_declared(&ctx.wake.record()?)).await
    }
}

setup_trigger runs once, when somebody activates the project. run fires every time the thing actually happens.

In the metadata:

"features": { "isTrigger": true },
"firesWith": { "chatId": "String", "text": "String", "caller?": "JsonDict" }

Setup

Read your inputs, decide what should wake you, and register it. That is all.

You never listen for anything yourself. No socket, no polling loop, no webhook handler. You say what should wake you and the runtime does the holding, the renewing and the checking that the event is real.

Two things to know about it.

Setup reaches out. A trigger that waits for Slack messages registers a subscription with Slack right there. If that cannot be done, activation fails on the spot naming what is missing, rather than leaving a trigger that looks armed and is dead.

Your inputs are frozen at that moment. Whatever they were is snapshotted alongside the registration and replayed onto ctx.inputs on every firing. The nodes upstream do not run again per event. When somebody changes one, they have to run weft resync, and that is why.

Register once per node per setup. A second call is a loud error rather than a trigger that quietly never fires.

Firing

run gets the event on ctx.wake, a bag with the same methods as ctx.inputs. Your declared inputs are there too, replaying the snapshot.

let chat_id: String = ctx.wake.get("chatId")?;

ctx.fan_declared(&ctx.wake.record()?) is the common shape: take the whole payload and fan its keys onto your same-named output ports, skipping anything you did not declare.

firesWith

Say exactly what the event carries. The ? goes on the name:

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

It is checked both ways before your body runs. A payload missing a field you declared without ? is refused, and so is one carrying a field you never declared, at the top level and nested. weft run --fire checks a payload you typed by hand against it too.

One honest caveat: nothing enforces at build time that a trigger has a firesWith, or that a non-trigger does not. A repo test holds the shipped catalog to both. A node you write is held to neither, and a typo in one of the type strings is not caught when the metadata loads, so it reaches a real firing before it fails.

What can wake you

KindWhat it is
TimerA delay, a date, or a cron schedule. A delay counts from when it was asked for: the activation for a trigger, the await_signal call for a node that waits
FormA person filling something in
RouteAn inbound HTTP request
SocketAn inbound WebSocket
SocketListenA socket weft dials out to and holds
StreamListenA TCP or TLS stream
SseSubscribeA server-sent-event stream weft subscribes to
PollEndpointAn address weft checks on a timer. If your node started an outside job and waits on its status address with await_signal, the first check runs at once, and the first answer that passes the filters (status matching COMPLETED|FAILED, say) is what await_signal returns
ProviderEventsA service’s own events, by topic, through its recipe

If your node registers a SocketListen or StreamListen with a connection, the text frames it sends (and a stream’s address) can name the connection’s stored values as {name}, and the value goes in as it is. When the value is something a person typed into a line protocol, like a password in an IMAP LOGIN, write {name|quoted} instead: weft wraps it in " and escapes \ and ", so a1 LOGIN {user|quoted} {password|quoted} signs in whatever the password holds. A value with a line break in it cannot be quoted and is refused rather than sent as a second command.

ProviderEvents is the one to reach for when the service has a recipe, because it takes care of both roads: a provider that pushes to you, and one you have to dial. Go and read events from a service.

Two things a trigger cannot do

It cannot be wired from another trigger, and nothing downstream of a trigger can be an infra node. Both are refused at compile time, because a trigger’s inputs are frozen at setup and provisioning happens before any event exists.

It also cannot sit inside a loop. It registers once for the project, so a per-iteration one is not a thing.

Testing one

rig.run_setup_trigger(&CronNode, json!({ "cron": "0 0 9 * * *" })).await.ok()?;
assert_eq!(rig.registered_signals().len(), 1);

let outcome = rig.wake(json!({ "scheduledTime": "2026-01-05T09:00:00Z", "actualTime": "2026-01-05T09:00:01Z" }))
    .run(&CronNode, json!({})).await.ok()?;

fake is the top tier for a trigger. The live rig drives a plain run body, so a live test on a trigger is refused.

Talking to a live caller

Most runs have nobody waiting. A run started by an HTTP request or a WebSocket does, and that caller is holding a connection open while your program works.

Your node gets at them through the ctx.

let caller = ctx.http_caller().await?;
caller.respond(OutboundChunk::json(json!({ "ok": true }))).await?;

Finding out whether there is one

CallWhat you get
ctx.is_api_call()Whether an HTTP caller is attached
ctx.is_websocket()Whether a WebSocket one is
ctx.caller()The caller as a handle, or None
ctx.caller_request()What they sent to open the exchange
ctx.caller_data_type()Whether this connection speaks bytes or JSON

Those are two separate questions rather than one enum, because a node can branch three ways: HTTP, WebSocket, or nobody.

ctx.caller_request() does not wait for the connection, because the handshake is known the moment the run starts. It gives you the method, the path, the route parameters, the query, the headers and whatever identity the gate established.

Getting the handle

CallGives youFails when
ctx.http_caller().await?An HTTP caller, attached and connectedThere is none, or it is a WebSocket
ctx.ws_caller().await?A WebSocket callerThere is none, or it is HTTP
ctx.live_caller().await?Whichever is connectedThere is none

Each of those folds together “is anybody there”, “is it the right protocol” and “wait for the connection” into one call. Use the specific one when your node only makes sense behind a route, and live_caller when it works either way.

Answering over HTTP

caller.respond(OutboundChunk::json(json!({ "answer": text }))).await?;
CallWhat it does
respond(body)Send the whole answer and finish
respond_with(head, body)The same, choosing the status and headers
write(chunk)Send a piece, keeping the connection open
write_with(head, chunk)The first piece, setting the head
close() / close_with(head)Finish

Streaming is write repeatedly, then close. That is how an answer appears in somebody’s browser a word at a time while your model is still writing it.

The head can only be sent once, and sending a body after the exchange ended is an error rather than a silent nothing.

Talking over a WebSocket

let ws = ctx.ws_caller().await?;

while let Some(msg) = ws.recv_next().await? {
    let reply = handle(msg)?;
    ws.send(OutboundChunk::json(reply)).await?;
}
CallWhat it does
send(msg)Send one message
receive()Wait for the next one
recv_next()The same, with None when the caller has gone
request(msg)Send, and wait for the reply to that
close() / close_with(reason)End it

There is also a cursor, so a node joining late can read from a chosen point rather than only from now: cursor(), cursor_from_start(), cursor_at(offset) and cursor_including_last().

A caller is not durable

This is the thing to hold on to. A caller is a socket in one worker. It is not written down and it cannot be rebuilt.

So a run holding one keeps its worker alive, and a node in that run cannot park for a week the way a human step does. If your program needs both, do the long wait in a different run and answer the caller with something it can come back for.

If the caller goes away, that is not your node failing. It reads as the run being cancelled, which your node can notice with ctx.is_cancelled() and stop doing expensive work.

In a test

rig.attach_caller(conn);

The fake rig gives you a caller to talk to, so a route node’s behaviour is testable without an install or a real socket.

Streams and buses in Rust

For which of the two you want, go and read streams and buses. This is the Rust on both sides.

Producing a stream

Declare a Generator[T] output, then emit as many times as you like:

for chunk in response_chunks {
    ctx.pulse_downstream(NodeOutput::new().set("chunk", chunk)).await?;
}

Each emission is one item, checked against the element type. When your body returns, the port closes and that is the end of the stream. To end it early, ctx.close_port("chunk"). After a close, nothing can follow.

pulse_downstream sends and carries on. If you are producing faster than the consumer reads, use yield_downstream, which waits until each item is taken.

If you do neither and run far ahead, you hit the cap at 4096 un-taken items and the emit fails, telling you the three ways out: yield instead of pulse, let the consumer catch up, or ctx.set_max_buffered_items("chunk", n).

Consuming a stream

Read the port like any input, then pull:

let rows: Generator<Row> = ctx.inputs.get("rows")?;

while let Some(row) = rows.next().await? {
    // one row at a time
}

Your body starts when the first item arrives, so you are running while the producer is still producing.

next() waits for the next item and gives you None once, at a clean end. try_next() is the same without waiting, answering Item, Empty or Finished. drain() takes the lot and waits for the end. end() tells you whether the producer finished or failed, or None while it is still open.

The ? matters. If the producer failed, that is where you find out, rather than getting a clean end you would mistake for an empty stream. drain errors without handing back the partial list, for the same reason.

Reading the port twice gives you two handles over one stream, sharing a position, not two copies.

Your node cannot durably suspend. A stream cannot be replayed, so ctx.await_signal in a stream consumer is refused. Do the waiting upstream or downstream.

Hosting a bus

let bus = ctx.open_bus("channel", BusOptions::default(), "host").await?;
bus.send(MessageKind::Json, json!({ "text": "hello" }))?;

open_bus does the whole producer move: makes the bus, emits the marker on that port, and registers your name. The guard closes the bus when it drops, so every way out of your function, including a panic, ends the channel instead of leaving readers parked forever.

Joining one

let bus = ctx.join_bus("channel", "translator")?;
bus.wait_for("host")?;

let mut cursor = bus.cursor();
while let Some(msg) = cursor.next().await {
    // ...
}

join_bus resolves the bus on that input and registers your name, with the same close-on-drop guard.

For an observer that must not close the bus, a debug tap that comes and goes, use ctx.bus_from_input("channel") instead.

Registering is the “I am here and ready” moment. A node with a slow warmup should hold the bus and register when it is genuinely ready, because that is what everybody else’s wait_for is waiting on.

Each reader has its own cursor. Reading does not consume, so ten readers all see everything.

wait_for(name) parks until that name is live, or returns an error if the bus closed first. You never have to decide whether a name will ever turn up: the engine watches the run, and when a wait can no longer be satisfied it closes the bus and every waiting cursor wakes.

Choosing options

BusOptions::default()
    .ephemeral(true)
    .window(256)
    .payload(BusPayload::Bytes)
OptionDefaultWhat it decides
ephemeralfalseWhether payloads are recorded, or only counts
window64 messagesHow far back a reader can reach
payloadJSONJSON or bytes. Frozen at creation; sending the other shape is refused
journal_window1 secondHow often the record is written
metanothingDetails every reader can read off the marker: a sample rate, dimensions. Keep it small, it rides the marker

Ephemeral is for a firehose. Video frames should not stall because a reader is slow, and should not end up in permanent storage either. A slow reader on an ephemeral bus silently skips to the oldest message still held, because there is no backpressure: the camera does not wait.

On a journaled bus, a send is refused if the record cannot be written, before anything is appended. On an ephemeral one it never is, because its record is notes rather than the data.

In both, who joined and who left is always kept, so nothing a reader needs can hide in a gap.

What a send tells you

Failures are values, never silent drops: the bus was closed, you never registered, you sent the wrong payload shape, or the record is degraded.

The one thing to remember

A bus lives exactly as long as its worker. It is not rebuilt when a worker restarts, and a marker resolved afterwards fails saying the bus is unknown.

It is for nodes that are alive together right now. Anything that has to survive goes in storage, or through a step that ends.

Cancelling and steering runs

Nothing kills your node

A cancelled run trips a flag. Your node is what acts on it. If your body never looks, nothing stops it, and the only sign is a run that never ends.

So anything that waits needs a way out:

let cancel = ctx.cancellation();

tokio::select! {
    result = do_the_long_thing() => result?,
    _ = cancel.cancelled() => return Ok(()),
}

For a loop, the cheap synchronous read:

for row in rows {
    if ctx.is_cancelled() { return Ok(()); }
    ...
}

That one is cheap enough to call every iteration.

The flag is sticky. Once it is set, every later check sees it, so there is no race between the cancel arriving and your next look.

What needs an arm: every wait, every long call to somebody else, and every read that could block for minutes.

Stopping other runs

A run can tag itself, and a run can stop every run carrying a tag.

ctx.tag_execution(["chat_7"]).await?;
ctx.stop_tagged("chat_7", StopSelf::Keep).await?;

That is the debounce shape. Somebody sends three messages in a row; the third run stops the first two and answers once.

What it does
StopSelf::KeepStop the others, keep running
StopSelf::IncludeStop everything carrying the tag, including this run

It reaches running executions, ones parked on a signal or a timer, whose wake is erased so they never come back, and ones whose wake is already on its way, which find the run gone and do nothing. Each one is recorded as cancelled, naming this run and the tag, so it reads as a decision rather than a failure.

It never crosses a project.

Tag first, then stop

StopSelf::Keep only reaches runs that took the tag before this one did.

That is what stops two runs killing each other. Two messages arriving milliseconds apart both say “stop the others, keep me”, and because the order is by when each tagged itself, the later one survives and the earlier one stops. Without that rule you would get both dead, or neither.

So tag at the top of your node, then stop. A node that stops before it tags is not in the order yet.

The stop is not immediate

stop_tagged returns when the request is durably queued, not when the other runs are gone. Do not write a node whose next line assumes they stopped.

A tag is [A-Za-z0-9_-], one to sixty-four characters, but you can hand either call any string that is not empty. If you pass a chat id or an email address, weft keeps the readable part, swaps every other character for _, and adds a short fingerprint of the original, so 49151@s.whatsapp.net becomes 49151_s_whatsapp_net- followed by sixteen hex characters. Both calls and the runs list’s tag filter do this the same way, so the same value always lands on the same tag, and two values that only differ in the swapped characters never share one. Tagging twice with the same tag does nothing, which is what makes it safe to replay.

In a test

The fake rig does not stop anything. It records what your node asked for, and you assert on that:

assert_eq!(rig.execution_tags(), vec!["chat_7"]);
assert_eq!(rig.stops().len(), 1);

Taking down what your own run uses

StopSelf is also the last argument of every call that takes part of the project down (ctx.infra("bridge").stop(..), terminate(..), ctx.trigger("receive").deactivate(..), ctx.runs().clean(..)). For what Keep and Include do there, go and read taking something down from a run.

In a test, rig.program_calls() lists every such call with the StopSelf it passed. If your node reads something back, such as an infra copy’s status, rig.answer_program_call("weft.infra.status", ..) sets what that call returns in the test.

Infrastructure nodes

Some nodes need something running: a database, a model server, a bridge that holds a session open. Your node returns a description of it, and the supervisor makes what runs match it: Docker containers on your machine, or a Compute Engine machine of its own on a cloud install.

"requires_infra": true,
"images": ["images/credential"],
"publishes": "postgres"
async fn provision_infra(&self, ctx: InfraProvisionContext, input: ValueBag)
    -> WeftResult<InfraSpec>
{
    let database: String = input.get("database")?;
    Ok(InfraSpec {
        units: vec![Unit {
            name: "db".into(),
            containers: vec![Container { /* image, env, ports, probes */ }],
            ..Default::default()
        }],
        volumes: vec![Volume { /* the disk */ }],
        endpoints: vec![Endpoint { name: "sql".into(), /* ... */ }],
        ..Default::default()
    })
}

Declaring requires_infra: true without writing provision_infra is an error naming your node, rather than a silent nothing.

The spec

FieldWhat it holds
unitsWhat runs. Most nodes have exactly one
volumesDisks, kept across stop and upgrade by name, and scratch space emptied at each start
endpointsNamed ports your node and the project’s other nodes reach it on, and whether anything outside the project may
keepOnTerminateDisks to keep even through a terminate, for the next weft infra start to pick up

A unit is a group of containers that run side by side, with optional init containers that run one after another before them. It also says what machine it needs (machine: cpu, memory, and gpu with a kind and a count) and what stop does to it (onStop: stop it, or keep it running until terminate). On a cloud install, weft picks the cheapest machine that holds those numbers, shared-core ones included: a quarter CPU with 1Gi is Compute Engine’s e2-micro. A number left unset is the sum of the containers’ own limits. If you need one machine type in particular, machine.kind names it (n2-highmem-8) and skips the pick. On your machine weft caps each container that has no limits of its own at the unit’s numbers, and refuses a unit that asks for a GPU you do not have.

Whoever uses your node pays for that machine, so if the right size depends on their load, give the node inputs for it and read them into machine: the Postgres node takes cpu, memory and machineType. On your machine weft cannot pick a GPU by kind, so a unit that asks for any GPU gets every GPU the machine has, and weft infra start and weft infra status warn you about it.

A unit asking for one L4 GPU, with the CPUs and memory beside it:

Unit {
    name: "model".into(),
    machine: MachineShape {
        cpu: Some("8".into()),
        memory: Some("32Gi".into()),
        gpu: Some(Gpu { kind: "nvidia-l4".into(), count: 1 }),
    },
    ..Default::default()
}

The kinds a cloud install attaches are nvidia-l4 (1, 2, 4 or 8 on one machine), nvidia-tesla-t4 and nvidia-tesla-p4 (1, 2 or 4), and nvidia-tesla-v100 (1, 2, 4 or 8). Any other kind or count is refused when the machine is picked, and the refusal says what is allowed. The CPUs and memory you ask for must also fit a machine with that many GPUs.

Each container also takes two probes: readiness says when it is up, and liveness restarts it after failureThreshold failures in a row.

The spec has to be stable

Write the same spec every time, for the same inputs. weft compares what you return against what is running, and re-applies when they differ.

So never generate a password inside provision_infra. Every call would produce a different spec, and your database would be redeployed on every check.

If your container mints its own credential on first boot, that is what ctx.publish_access is for: the container makes it, your node publishes it once, and ctx.published_access() reads it back afterwards.

Local images

A Dockerfile in your node’s own folder, named in images:

postgres/database/
  metadata.json       "images": ["images/credential"]
  images/credential/
    Dockerfile

The last path segment is the name you reference in the spec. The path is relative to your node’s directory.

An upstream image reference passes through exactly as you wrote it, and a mutable tag is not resolved to a digest. So postgres:16 rolling underneath you produces no change in the spec and no re-apply. Pin by digest if you want an image change to actually land.

Reaching it

let endpoint = ctx.endpoint("sql").await?;
let (host, port) = endpoint.host_and_port()?;

ctx.endpoint(name) resolves one of your declared endpoints and waits until something actually answers there, rather than handing you an address the moment the container reports ready.

CallGives you
url()The address, cached, no round trip
host_and_port()The two apart, for a client that wants them that way
call(method, path, body)An HTTP request against it, retried once through a routing gap
public_url()For an endpoint exposed with Expose::Public, the address a caller outside the install uses; None for any other endpoint

By default (Expose::Project), only the project’s own workers and infrastructure can reach an endpoint. If you want anything else to reach it, set expose:

  • Expose::SameNetwork opens it to programs on the install’s own network: anything running on your machine locally, the install’s private network on a cloud, but never the internet. A frontend uses this to reach a program’s database.
  • Expose::Public { path: "/hooks" } opens it to the internet, over HTTP, at the install’s public address. Hand the caller (a provider delivering webhooks, say) public_url().

The install serves every project at that address, so your /hooks lives under a prefix of its own: public_url() is <address>/infra/<project>/<copy>/hooks (<copy> names this copy of the node), and the install strips the prefix again, so your container still sees /hooks. weft infra status prints the same address under the node. On a cloud install it is the install’s own address. On your machine it is the tunnel’s address while the tunnel is open (a public address), and http://127.0.0.1:14111/... while it is closed, which nothing on the internet can reach.

If an endpoint hands out a credential, keep it to the project. The Postgres node shows the split: the port Postgres answers on is SameNetwork, because reaching it still takes a password, but the small server that hands out that password stays reachable only by the project.

It works during provisioning after the apply, and in every later phase once the infrastructure is running. If the endpoint is not declared, or the infrastructure is down, the error says which and points at weft infra status.

When a program marks your node @per_instance, each instance of the program gets its own container, and ctx.endpoint answers with the copy of the instance the run is for; your node’s code does not change. Each copy has its own address, so its public_url() is its own too. A connection your node publishes (ctx.publish_access) from an instance’s copy is recorded as that instance’s. For what an instance is, go and read programs with instances.

Outputs saved with the infrastructure: baking

If an output only says where your infrastructure is or how to connect to it (an address, a database connection), it stays the same from one run to the next, so weft can save it when the infrastructure is applied and stop running your node just to hand it out. Mark it baked in metadata.json:

{ "name": "access", "type": "Access", "baked": true }

(Baked outputs have nothing to do with weft bake, which prepares a trigger’s settings.)

Only an infrastructure node can bake an output: on any other node, the catalog refuses to load the metadata and names the output.

Every time the infrastructure is applied (weft infra start, weft infra upgrade, weft infra rebake <node>, or a program’s own ctx.infra(..).start()), weft runs your node’s run right after and saves what it sent on its baked outputs alongside that infrastructure. A baked output your run sent nothing on keeps the value saved last time.

While the infrastructure is running, if everything a run reads from your node comes from baked outputs that have a saved value, your node does not run: the saved values go down its wires instead. Nodes that only fed your node do not run either. Your node’s log says why: did not run: everything this run reads from it is baked, so its saved <outputs> went out instead. The Postgres node bakes access, so a route that queries the database gets the saved connection without the Postgres node running. If you want your node to run anyway (to debug it), name it in the run: weft run --target <node> or --from <node> always runs it.

If a run also reads an output that is not baked (a bridge’s status, which changes while the bridge runs), your node runs as usual, and if its run sends nothing on a baked output, the saved value goes out on it instead of the output closing. Only wires into nodes that are part of this run count: a status wired to a node outside the run does not make your node run.

If your container changes a value itself (a password reset from a button, say), have it POST the new value to the address in WEFT_VALUES_URL:

curl -X POST "$WEFT_VALUES_URL" -H 'Content-Type: application/json' \
  -d '{"connection": {"password": "the-new-one"}}'

connection changes values inside the connection your node published, and outputs replaces saved baked outputs by name ({"outputs": {"<output>": <value>}}).

weft sets WEFT_VALUES_URL in every container of your unit, init containers included, and refuses a spec that sets it. A push only ever changes the copy whose container sent it. weft does not check a pushed value against the output’s type. A saved value that does not fit its output’s type, pushed or left over from older code, is never handed out: a run that reads it runs your node instead, and if your run sends nothing on that output the run fails saying what this node's infra saved for its baked outputs no longer fits them.

The request answers 200 once weft has written the value. If you name a key the connection does not store or an output with nothing saved, or send connection from a node that published none (or several), it answers 422 with a body that names the problem, and nothing is written. Any other answer means nothing was written either, and 502 means weft could not be reached at all.

If you want a button for this, give one of the items on your container’s panel an action: {"label": "Reset password", "actionKind": "reset_password", "confirm": "<what to ask before pressing>"}. For serving a panel, go and read showing something in the graph. A press reaches your container as a POST to /action with {"action": "<actionKind>", "payload": null}, or with the button’s own payload if you gave it one. For how to answer it, go and read letting other nodes reach it.

If the push fails, or a value changed without anything telling weft, the person running the program fixes it with weft infra rebake <node> (add --instance <id> for a @per_instance node’s copy). It applies your infrastructure again, restarting nothing that already matches its spec, then runs your node’s run and saves what it sends. That only helps if your run reads the current value from your infrastructure instead of trusting what it published last time. The Postgres node does this: it asks its credential container whether the password it holds is still the current one, and reads the new one when it is not.

If your container pushes from a button and the push fails, answer the press with {"result": {"error": "..."}} telling the person to run weft infra rebake <node>, the way the Postgres node’s Reset password button does. You do not need to add that hint anywhere else: when any node fails after opening the connection your node published, weft adds a line to that node’s error naming weft infra rebake <node>.

If you change what your run emits on a baked output, runs that skip your node keep handing out the old saved value until it bakes again. weft infra upgrade builds your current source, then stops the infrastructure and starts it again. If you would rather restart nothing, build first (weft build, or any weft run) and then run weft infra rebake <node>, which runs whatever weft built last.

Without --instance, the upgrade stops the program’s shared infrastructure and starts it again. A @per_instance node’s copies are left alone, so run it with --instance <id> for each instance whose copy should bake your new code. For what an upgrade does to running work and to triggers, go and read choosing what happens to work in flight.

Letting other nodes reach it

If other nodes need to talk to your service (say, a WhatsApp bridge that every send node goes through), pass them a handle instead of url(). The address changes every time your service is set up again. The handle names your node, as it is named in the program, and its endpoint, so it still finds the service after that.

Declare an output typed Infra and set it to the endpoint’s handle:

{ "name": "bridge", "type": "Infra" }
let api = ctx.endpoint("api").await?;
ctx.pulse_downstream(NodeOutput::new().set("bridge", api.infra_handle())).await

A node that uses it declares an input typed Infra and resolves it:

let bridge: InfraHandle = ctx.inputs.get("bridge")?;
let bridge = ctx.endpoint_of(&bridge).await?;
let sent = bridge.action("sendMessage", json!({ "to": to, "text": text })).await?;

ctx.endpoint_of gives back the same handle ctx.endpoint does, so url() and call(..) work on it as well. action(name, payload) posts {"action": name, "payload": payload} to the service’s /action and gives you the result of its answer. Your service answers {"result": ...}. action returns an error when your service answers non-2xx, when the answer has no result, or when result carries an error string (the error then reads <action>: <error>). An error that is not a string comes back as part of the result. The buttons of a live panel post to the same /action, so one handler in your container serves both.

An Infra input only takes a handle from an Infra output. The compiler refuses a String wired into it, and it refuses a value typed into it by hand. A handle only works inside its own project. It only reaches infrastructure the program still declares. For a node marked @per_instance, it reaches the copy that belongs to the run’s instance.

A long job in your container

If your service runs something long (a render, a training run), have it answer POST /jobs with an id at once and a status route like GET /jobs/<id>, then wait on that route the way a node waits on a provider’s job: start it inside ctx.run and park with PollEndpoint on format!("{}/jobs/{id}", api.url()). For the whole pattern, go and read waiting on a job a service runs.

api.url() is the address your workers reach, and the checks are made by the listener, which on a local install sits on the machine rather than in Docker’s network. Before every check it asks for the same endpoint’s address as weft’s own roles reach it, looked up in this project’s infra only, so the address you pass works as it is.

If somebody stops the run while it waits, none of your code runs: the run ends cancelled and the checks stop, but the job in your container carries on. If it should stop too, have the container give up on a job whose status nobody has asked for in a few intervals; terminating the infra stops it as well.

A live panel

If your container serves /live, name that endpoint and the editor shows the panel on your node:

"features": { "liveEndpoint": "api" }

That is how a bridge shows the QR code you have to scan. Go and read showing something in the graph.

What the supervisor does

It takes an exclusive lease on the project, applies your spec, and watches whether what it created is healthy. For what happens when a supervisor dies halfway through a change, go and read the supervisor.

A unit that was healthy and stays not ready past its flaky window (health.flakyAfterSeconds, 30 seconds by default) is marked flaky, and one that stays ready again past health.recoveryAfterSeconds (also 30) is marked running again. weft infra status shows which.

There is no time limit on coming up, because a model server pulling weights can take a long time. Somebody waiting on yours reads weft infra logs <node> and cancels if it will never come.

Two things a user has to do themselves

Nothing starts your infrastructure for them. Not a run, not a build, not activation. weft infra start is a verb they type, because a container costs money and weft will not spend it on a click that did not mention one.

And weft infra stop keeps the disk while weft infra terminate deletes it. If your node has data worth keeping even through a terminate, name its disk in keepOnTerminate. The next weft infra start of the same node (the same instance’s copy, for a node marked @per_instance) finds that disk and mounts it again, data and all. If you want a kept disk gone, drop it from keepOnTerminate, start the node once so the copy knows about it, and terminate.

A kept disk is deleted for you once its copy can never come back: when you remove the node from your program and run it again, when the node switches between shared and @per_instance (which leaves the old side’s copies behind), or when you remove the project with weft rm. This holds even if the copy was already terminated at the time. The supervisor sweeps for such copies on every ownership tick, so the disk goes within one tick of the change.

If you wipe an instance (the WipeInstance node, or ctx.infra(node).instance(id).wipe(..) from your own node), the kept disks of that instance’s copies go right away, including those of a copy that was already terminated.

Testing one

let outcome = rig.run_provision_infra(&PostgresNode, json!({ "database": "app" })).await.ok()?;
let spec = outcome.infra_spec()?;
assert_eq!(spec.units[0].name, "db");

fake is the top tier here too. For a node that calls its own infrastructure, declare where each endpoint answers, then what each call gets:

rig.declare_endpoint("credential", "http://localhost:8080");
rig.refuse_endpoint("credential", EndpointMethod::Get, "/password", 503, "starting");
rig.answer_endpoint("credential", EndpointMethod::Get, "/password", json!({ "password": "minted" }));
let outcome = rig.run(&DatabaseNode, json!({ "database": "app" })).await.ok()?;
assert_eq!(rig.endpoint_calls().len(), 2, "asked twice: refused, then answered");
CallWhat it does
declare_endpoint(name, url)Says the endpoint name answers at url. Without it, ctx.endpoint(name) fails the way it does when the infrastructure is not running
declare_public_url(name, url)Makes a declared endpoint public: ctx.endpoint(name)?.public_url() answers url
answer_endpoint(endpoint, method, path, answer)The next call to path on that endpoint answers answer (JSON)
refuse_endpoint(endpoint, method, path, status, body)The next call to path on that endpoint is refused with status and body, the way a service that is still starting refuses one
declare_infra(place, endpoint, url)Use this when your node reads an Infra input. It pretends the infrastructure node place shares endpoint at url, and returns the handle to set on that input
answer_infra(place, endpoint, method, path, answer)Queues the answer to the next call to path on that shared endpoint, one per call, the same way answer_endpoint does
endpoint_calls()Every call the node made to an endpoint, its own or a shared one, in order: place, endpoint, method, path, body
stop_after_calls(n)Presses stop, as weft stop would, once the node has made n calls (endpoint calls and web requests, counted together). The call that reaches n still gets its answer; 0 stops the run before it starts. This is how you test a node that loops on calls and must end cancelled when a person stops it

method is EndpointMethod::Get or EndpointMethod::Post.

Each answer and each refusal is used by exactly one call, in the order you declared them, so declare one per call you expect. That is how a node that asks twice and acts on the answer changing (refused, then answered) gets tested. A call with nothing left to answer it fails the run and names answer_endpoint, so a question your node should not have needed to ask shows up instead of being quietly answered.

Testing a node

Tests live beside the node, in a tests.rs in its own folder. An access node has none: the access_node! macro is its whole body, so there is nothing of yours to test.

tests.rs:

pub fn tests() -> Vec<NodeTest> {
    vec![NodeTest::fake("counts_words", counts_words)]
}

async fn counts_words(rig: FakeRig) -> WeftResult<()> {
    let outcome = rig.run(&WordCountNode, json!({ "text": "one two three" })).await.ok()?;
    assert_eq!(outcome.outputs["count"], json!(3.0));
    Ok(())
}

mod.rs bridges it in:

#[cfg(feature = "node-tests")]
mod tests;

#[async_trait]
impl Node for WordCountNode {
    #[cfg(feature = "node-tests")]
    fn tests(&self) -> Vec<weft::NodeTest> {
        tests::tests()
    }
    // ...
}

There is no registry and no list to maintain. The runner walks the nodes the binary already has and asks each one for its tests.

weft test-node WordCount        # one node
weft test-node slack            # one package
weft test-node                  # everything

Three tiers

TierWhat it getsCosts
basicNothing. A plain function testing your own logicNothing
fakeCanned HTTP, in-memory storage, canned signals, fake connectionsNothing
liveThe real runtime: real connections, real calls, real meteringReal money

Basic and fake run by default. Live never runs unless you ask, and the runner confirms before it does.

Basic and fake run right there with cargo: they need no Docker and no runtime, and only the packages you targeted get built.

Two things to know. A basic test is a plain sync function and the runner is already inside an async runtime, so building one inside it panics. And for a trigger or an infra node, fake is the top tier: the live rig drives a plain run body, so a live test on those is refused.

A fake test that never finishes fails by name after 30 seconds, and tells you where to look:

test 'reads the stream' did not finish within 30s. Something in it is waiting
for what it never gets: a cursor reading a bus nothing closes, a caller nobody
attaches, a signal nobody answers.

The fake rig

You wantCall
Run the noderig.run(&node, json!({...}))
Run its trigger setup, or its infrarig.run_setup_trigger(...), rig.run_provision_infra(...)
Canned HTTPrig.respond(method, path, body), rig.respond_status(...), rig.respond_raw(...), rig.respond_with_headers(...) for an answer that lives partly in a header (a created id, an ETag)
A server it cannot reach at allrig.fail_connection(method, path): the call errors with no status and no body
Check what it sentrig.requests(), rig.assert_sent(method, path)
What ctx.run recordedrig.recorded_steps(&node). Run the node twice on one rig and the second run replays the first’s steps and the signals it was answered with, like a resumed execution, so a publish that must happen once is something you can prove: two runs, one request. Each node keeps its own, so two nodes on one rig never replay each other’s
A connectionrig.access("slack"), rig.connection_value(...), rig.connection_permissions(...)
A trigger’s eventrig.wake(payload), rig.signal(payload)
A live callerrig.attach_caller(conn)
A bus going inrig.seed_bus(opts), giving you a writer and a marker
A bus it emittedrig.bus(&outcome.outputs["channel"])
A stored filerig.store_file(filename, mime, bytes) going in; rig.stored_bytes(key), rig.stored_meta(key) and rig.stored_files(scope) for what the node stored; rig.file_edits() for every in-place change, with the file, the versions and the diff
An infra endpointrig.declare_endpoint(name, url), rig.answer_endpoint(...), rig.refuse_endpoint(...)
What it asked forrig.registered_signals(), rig.awaited_signals(), rig.logs(), rig.execution_tags(), rig.stops()
A port’s type, for a node that takes added portsrig.output_type(port, ty), rig.input_type(port, ty)
An output something downstream readsrig.wire_output(port). Nothing is wired unless you say so. On a node with catchErrors, rig.wire_output("error") makes a failure come out on error, as it would in a run
A server that is there and will not talk, for a node that opens its own socketrig.hang_up_server(), a local port that hangs up on every connection. It is for raw socket clients (a database driver, a mail client): web calls in a fake test never reach the network, so for those use rig.fail_connection. If you want a refused connection, use this rather than a closed port: on some machines a closed port hangs until a timeout

The outcome gives you .ok() for the happy path, .failure() for the error string, .output(port) for one port, .infra_spec() for an infra run, and .outputs for the map.

Note the last group: in a fake test nothing is really stopped and nothing is really tagged. The rig records what your node asked for, and that is what you assert on.

Rig reference

If you need a rig call’s exact signature, or the fields of what it hands back, it is here.

The fake rig

Setting the run up:

SignatureWhat it does
respond(&self, method: &str, path: &str, body: Value)Answers a matching request with 200 and body as JSON. path may carry a query; parameter order and encoding do not matter
respond_status(&self, method: &str, path: &str, status: u16, body: Value)The same with your status
respond_raw(&self, method: &str, path: &str, status: u16, content_type: &str, body: impl Into<Bytes>)Raw bytes with a content type (XML, CSV, a picture, an event stream)
respond_with_headers(&self, method: &str, path: &str, status: u16, content_type: &str, headers: &[(&str, &str)], body: impl Into<Bytes>)The same plus response headers. The content type is its own argument, never a header here
fail_connection(&self, method: &str, path: &str)A matching request fails before any response, like a server that is down
signal(&self, payload: Value)Queues the answer to the node’s next ctx.await_signal, in order
signal_given_up(&self)Queues a wait given up instead: the node’s next ctx.await_signal fails with WeftError::WaitGaveUp, the way it does when a run that cannot pause holds the wait past its holdSecs
wake(&self, payload: Value)The next run’s ctx.wake: what a firing trigger received
instance(&self, id: &str)Makes every run on the rig a run for that instance
attach_caller(&self, conn: Arc<FakeCallerConnection>)Puts a scripted live caller on the runs, what a Route or Socket run carries
seed_bus(&self, opts: BusOptions) -> WeftResult<(BusHandle, Value)>A bus you write into, and its marker to put on a Bus input
access(&self, service: &str) -> ValueA connection marker for an access input
connection_value(&self, service: &str, name: &str, value: &str)A value the opened connection answers (conn.value(name))
connection_permissions(&self, service: &str, granted: &[&str])What the connection has granted, checked against what the input requires
published_connection(&self, service: &str, values: &[(&str, &str)])Says the node already published this connection on an earlier run
output_type(&self, port: &str, ty: WeftType)The type the compiler would give a port declared MustOverride
input_type(&self, port: &str, ty: WeftType)Declares an added input port and its type
wire_output(&self, port: &str)Wires an output downstream. Nothing is wired unless you say so
hang_up_server(&self) -> u16A local port that hangs up on every connection, for raw socket clients
declare_endpoint(&self, name: &str, url: &str)Where the node’s own infrastructure endpoint name answers
declare_public_url(&self, name: &str, url: &str)Makes a declared endpoint public at url
answer_endpoint(&self, endpoint: &str, method: EndpointMethod, path: &str, answer: Value)The next call to that endpoint and path answers answer
refuse_endpoint(&self, endpoint: &str, method: EndpointMethod, path: &str, status: u16, body: &str)The next call to that endpoint and path is refused
answer_program_call(&self, name: &str, value: Value)What the next program call of that name answers (weft.infra.status, …), in order
store_file(&self, filename: &str, mime_type: &str, bytes: impl Into<Vec<u8>>) -> ValueStores a file in this run’s scope and gives its value, for a file input
store_file_in(&self, scope: &StorageScope, filename: &str, mime_type: &str, bytes: impl Into<Vec<u8>>) -> ValueThe same in another scope

Running it:

SignatureWhat it does
run(&self, node: &dyn Node, inputs: Value) -> RunOutcomeRuns the node’s body with inputs (a JSON object of input name to value)
run_setup_trigger(&self, node: &dyn Node, inputs: Value) -> RunOutcomeRuns a trigger’s setup
run_provision_infra(&self, node: &dyn Node, inputs: Value) -> RunOutcomeRuns an infra node’s provision_infra

Looking at what happened:

SignatureWhat it gives you
requests(&self) -> Vec<SentRequest>Every web request the node sent, in order, answered or not
assert_sent(&self, method: &str, path: &str)Panics, listing what was sent, unless one request matched
stored_bytes(&self, key: &str) -> WeftResult<Bytes>The content stored under a key
stored_meta(&self, key: &str) -> WeftResult<StoredFileMeta>What storage records for it: keep, keep_ttl_secs, the size
stored_files(&self, scope: &StorageScope) -> WeftResult<Vec<StoredFileMeta>>Every file stored in a scope
file_edits(&self) -> Vec<FileEdit>Every change made to a stored file in place, in order: the file, the versions, the diff
bus(&self, marker: &Value) -> WeftResult<BusHandle>The bus behind a marker the node emitted
endpoint_calls(&self) -> Vec<EndpointCall>Every call to the node’s own infrastructure, in order
registered_signals(&self) -> Vec<(SignalSpec, Value)>What a trigger’s setup registered
awaited_signals(&self) -> Vec<SignalSpec>Every signal the node parked on
recorded_steps(&self, node: &dyn Node) -> Vec<AwaitedEntry>Every ctx.run step and signal the node’s run recorded
logs(&self) -> Vec<(LogLevel, String)>Every ctx.log line
execution_tags(&self) -> Vec<Vec<String>>The tags, one list per ctx.tag_execution
stops(&self) -> Vec<(String, StopSelf)>Every ctx.stop_tagged asked for
program_calls(&self) -> Vec<(ProgramCall, StopSelf)>Every program call made
minted_tokens(&self) -> Vec<MintedToken>Every instance token minted: instance, expires_in_secs, id
published_values(&self, service: &str) -> Option<BTreeMap<String, String>>What the node published as that service’s connection

The live rig

SignatureWhat it does
access(&self, service: &str) -> ValueThe test’s connection marker. The service must be the one the test declared
fixture(&self, name: &str) -> WeftResult<String>A --fixture value, or a failure naming the variable to set
store_file(&self, filename: &str, mime_type: &str, bytes: impl Into<Vec<u8>>) -> WeftResult<Value> (async)Stores a real file and gives its value
connect(&self) -> WeftResult<OpenedConnection> (async)Opens the test’s connection, for setup and cleanup calls of your own around the node
bus(&self, opts: BusOptions) -> WeftResult<(BusHandle, Value)>A real bus you write into, and its marker
wire_output(&self, port: &str)Wires an output downstream
run(&self, node: &dyn Node, inputs: Value) -> RunOutcome (async)Runs the node against the real runtime

LiveRig::new(..) is built by the runner, never by a test.

What comes back

RunOutcome, from every run:

Field or methodWhat it is
result: WeftResult<()>The body’s own result
outputs: serde_json::Map<String, Value>What each port emitted
closed_ports: Vec<String>Ports the body closed with ctx.close_port
infra_spec: Option<InfraSpec>The spec, on a run_provision_infra outcome
ok(self) -> WeftResult<Self>The outcome, or the body’s failure
failure(self) -> WeftResult<String>The failure’s message, or an error when the run succeeded
output(&self, port: &str) -> WeftResult<&Value>One port’s value, or an error naming the ports that did emit
infra_spec(&self) -> WeftResult<&InfraSpec>The spec, or an error when this outcome has none

SentRequest, one per request:

Field or methodWhat it is
method: StringUppercase, "POST"
path: StringThe path, no query
query: Option<String>The raw query string
body_text: Option<String>The body as text, when it was sent whole
body: Option<Value>The body parsed as JSON, when it parses
body_streamed: boolTrue when the body was streamed, which the rig cannot read: body and body_text are then None even though there was a body
headers: Vec<(String, String)>The headers, names lowercased
header(&self, name: &str) -> Option<&str>One header’s first value, any case

EndpointCall, one per call to the node’s own infrastructure:

FieldWhat it is
endpoint: StringThe endpoint’s name
method: EndpointMethodGet or Post
path: StringThe path called
body: Option<Value>The body sent

Live tests

weft test-node SlackSendMessage --tier live

The runner tells you it will spend money and asks. --yes skips that, and it can remember your answer.

For credentials, --connection slack=<grant-id> uses one you already have, and --key slack takes a throwaway one: each field comes from WEFT_NODE_TEST_SLACK_<FIELD> if it is set, or a prompt, never from the command line where other processes can read it. The grant is deleted afterwards.

Every prompt happens before any credential exists and before any signal handler is armed, so Ctrl+C at a prompt leaks nothing.

--fixture values, declared with .with_fixture(...) on a live test, are how a test names the real thing it needs: a channel it may post in, an account it may read. Read them with rig.fixture("NAME").

What to write

Ship a fake test for every node. If it talks to a provider, ship a live one on the cheapest real path you can find.

A panic fails that test, carrying its message, and never takes the runner down.

Packaging

Several nodes that belong together, sharing code and dependencies.

slack/
  package.toml
  api.rs                shared code
  metadata.json         optional defaults for every node here
  access/
    metadata.json
    mod.rs
  send_message/
    metadata.json
    mod.rs
    deps.toml

package.toml is short:

[package]
name = "slack"

[dependencies]
# cargo deps every node here gets

Its presence is what makes the folder a package, and the members are found by looking rather than listed.

Shared code

Any .rs at the package root becomes a module, and a node reaches it as super:

use super::api;

That is where the request-building and the error-reading for one service goes, written once instead of in every node that talks to it.

Each package compiles as its own crate, so a node sees its own package’s shared files and nothing else. Another package’s code is never on your path.

It also means editing one node recompiles that package and relinks, rather than rebuilding the world.

A shared file whose name is not a valid Rust identifier fails at build time naming the file.

Defaults

A metadata.json at the package root is a partial document every node inherits, key by key, with the node’s own value winning.

Put types, tags, an icon, a color or a service recipe there once.

type, label and description can never be defaults. They are one node’s identity, and the compiler refuses a package file carrying any of them:

package-level metadata.json must not set `type`: it is one node's identity,
not a package default

The merge is shallow: a node’s types replaces the package’s rather than adding to it.

When a folder does not load

If a node or a package has something wrong with it, only that folder is left out. Every other node still loads, and a program that never uses the broken one still compiles. A program that does use it fails with the reason:

node 'Poll' failed to load: package name 'feed' is used by more than one
folder (nodes/rss/feed, nodes/mine/feeds); ...

Names have to be unique across all your node folders: a node type, a service, and a package name (a package is named by its package.toml, a bare node by its folder). If two folders claim the same name, both are left out and each error names the other, so you pick which one to rename.

If you write a package.toml before any of its nodes, the package just waits, and it loads once a node folder with a metadata.json and a mod.rs is there. weft describe-nodes lists everything that was left out, and why.

deps.toml

Per node, and every section is optional.

[dependencies]
reqwest = { workspace = true }

[system.runtime.apt]
default = ["ffmpeg"]

[system.build.apt]
debian_12 = ["pkg-config", "libssl-dev"]

[build.env]
SOME_PATH = "{{catalog_path}}/vendor"
SectionWhat it is for
[dependencies]Cargo crates this node needs
[build-dependencies]Cargo build-deps, for the package’s own build.rs
[system.build.<manager>]OS packages needed to compile, thrown away before the runtime image is sealed
[system.runtime.<manager>]OS packages needed to run
[build.env]Environment during cargo build. {{catalog_path}} expands to your node’s folder inside the builder

Managers are apt, apk, yum and brew. Under each, a key per distribution like debian_12 or alpine_3_19, or default for all of them. weft looks for the exact key, falls back to default, and fails only when neither is there.

[build.env] is deliberately narrow. Real build logic goes in a build.rs.

What you get without asking

weft, tokio, serde, serde_json, async-trait, anyhow and tracing are always there. So is weft-providers.

weft also re-exports serde_json, reqwest, reqwest_middleware, async_trait and inventory, so you can name those types without declaring a dependency on them.

Local images

An infra node’s containers are built from Dockerfiles in its own folder:

postgres/database/
  metadata.json       "images": ["images/credential"]
  mod.rs
  images/credential/
    Dockerfile
    bootstrap.py

The path is relative to the node’s own directory, not the package root, and the last segment becomes the name you reference in the spec.

How connections work

A credential never appears in your program. It lives in weft’s own store, and what travels a wire is a reference with nothing secret in it.

Four things that sound alike

What it isWho writes it
The recipeHow a credential is obtained, how a request is signed, what the permissions are, how events arrive. Pure declaration, no codeWhoever wrote the access node
A registered appOne OAuth application: its client id, its secret, the permissions it asks forThe operator, in the apps file
A connectionOne account somebody connected. Its sealed values, its permissions, who it belongs toThe person who connected it
The Access valueThe reference your program passes aroundThe runtime, when it builds a node’s inputs

For OAuth the first two are different things: the app is the software asking, and the connection is the account that said yes. A pasted API key needs no app and is still a connection, and so is a hostname, a username and a password for a mail server.

What actually travels

{
  "__weft_access__": {
    "accessId": "11111111-2222-3333-4444-555555555555",
    "service": "slack",
    "identity": "Q @ Acme",
    "requiresPermissions": ["chat:write"],
    "requiresValues": []
  }
}

The two requires lists are stamped on per consuming node, from what that node said it needed. So the same connection carries different requirements depending on which step is about to use it, and one that falls short is refused when that step opens it rather than halfway through a call.

Shared, or your own

DoorWhat it is
sharedOne click on a credential weft holds: a registered application, or the runtime’s own key, spending its credit
ownYou bring your own. A page with the paste fields, and sometimes a guide or a button that creates the application for you

own is the default. Offering shared is something a service’s recipe has to declare, and what sits behind it is whatever the operator put in the apps file.

A permission a recipe marks as only working on your own account can never be served by the runtime’s credential. The editor greys that option out and resolution refuses it.

An instance’s own connections

In a program with instances, an access step whose connection is written account: @instance_filled holds no connection in the source: each instance gets its own, and a run for that instance uses it. For how that works, go and read programs with instances.

What weft can actually tell you about a credential

Providers differ in how much they admit, and weft records which kind of answer it got.

What the provider doesIs it authoritative?
States exactly what it grantedYes
Lets weft ask the credential to describe itselfYes
Confirms only that the credential is aliveNo
Says nothing at allNo

That distinction decides what gets blocked. A permission weft knows is missing blocks the node that needs it. A permission that is only claimed, or unknown, lets the call through, because refusing would block every pasted key on every service that reports nothing.

Stored values are simpler and have no unknown case: if a node needs imap_host and the connection has not got one, the node never starts.

Connecting also runs the service’s own check, but only when that check is free. Anything paid, or ambiguous because it might bill depending on the account, is skipped, so a bad credential sometimes shows up for the first time when a step tries to use it.

One thing that is always checked: if you tick permissions and the provider hands back fewer, the connect is refused naming the one you did not get.

Refreshing

Lazily, when a step opens the connection, and only if the token expires within the next minute. There is no background keep-alive.

Two things opening the same connection at once queue behind each other, so it refreshes once and both get the fresh values.

When a refresh cannot happen, it fails loudly rather than retrying into nothing:

the 'slack' access needs reconnecting: the provider issued no refresh token,
so the expired sign-in cannot be renewed

A pasted credential that expired says so too, because there is nothing to refresh it with.

Where the secrets are

Sealed with AES-256-GCM, under CREDENTIAL_ENCRYPTION_KEY, which is 32 bytes in base64:

openssl rand -base64 32

Service names, client ids, which permissions were granted and who owns what stay readable, so weft can query them. Only the credentials themselves are sealed.

Without that key set, weft uses a public development key built into its own source and warns you once. That key is in the repository, so anyone with a copy of your database can read every credential in it. Set a real one before you store anything you care about.

There is no rotation. Change the key and everything sealed under the old one is unreadable:

stored credential data does not open under the current CREDENTIAL_ENCRYPTION_KEY;
if the key changed, the sealed rows it wrote are gone: reconnect the affected
connections

Putting the old key back reads them again. Losing it means reconnecting everything.

The rest

For connecting one, go and read connecting an account. For using one inside a node, using a connection in a node. For writing a recipe, declaring a service.

The apps file

access-apps.json is where the operator puts the applications and keys this installation offers as one-click connections. Whatever is in it shows up in everybody’s connect panel.

Start from the example and keep what you want:

cp access-apps.example.json access-apps.json

Fill in the services you expect to use often. A service you set up here is one click forever after, and one you skip asks each person to bring their own the first time they need it, which is fine for something you touch once.

The shape

A service name maps to a list, always, even with one entry. Keys starting with _ are ignored, which is how the example file carries its own notes.

{
  "openrouter": [{
    "kind": "api_key",
    "label": "Runtime key",
    "key": "sk-or-..."
  }]
}

A pasted key

KeyWhat it is
kind"api_key"
labelThe name the connection list shows
keyThe credential. At most one api_key per service

This is what “use ours” hands out. Calls on it spend money on that account, so keep the file private.

An OAuth application

{
  "slack": [{
    "kind": "oauth_app",
    "label": "Slack",
    "covers": ["chat:write", "channels:read", "channels:history"],
    "auth_url": "https://slack.com/oauth/v2/authorize",
    "token_url": "https://slack.com/api/oauth.v2.access",
    "client_id": "...",
    "client_secret": "...",
    "events": { "signing_secret": "..." }
  }]
}
KeyWhat it is
labelUnique within the service
coversExactly the permissions this app asks for, by their ids from the service’s catalogue
auth_url, token_urlThe provider addresses this app signs in against, copied from the recipe
client_id, client_secretFrom the provider. client_secret is absent for a public PKCE app
eventsOnly if this app receives pushes. Whatever the recipe needs to verify them, like a signing secret

Permissions are fixed per app. People pick an application rather than ticking boxes, which is why you can list several for one service: a cheap tier that avoids a provider’s review process, and a fuller one beside it. Each shows up as its own labelled choice with its permissions listed.

The addresses are pinned here, and a recipe that tries to substitute a different sign-in address is refused. A connection through a registered app only ever talks to the addresses you wrote.

You also have to register the callback URL the connect panel shows you as the app’s redirect URL, and re-register it whenever your public address changes. Some providers, Slack among them, only accept https callbacks.

Making it take effect

On your machine, weft reads the file each time it needs one of its keys, so an edit takes effect at once. If the file is gone, weft has no shared apps or keys until it is back. On a cloud install the file is the WEFT_ACCESS_APPS secret of your fork: if you want to change it, update that secret and run install on GCP again (deploying to your cloud).

If you want to keep it somewhere else, point WEFT_ACCESS_APPS_FILE at it and run weft daemon start. If the file is there but cannot be read or parsed, every lookup of its keys fails with an error naming the file, because shipping no apps quietly shows up later as a service nobody can connect on a node the operator thought was configured.

What it refuses

  • A service whose value is not a list
  • An entry with no kind
  • Two entries with the same label in one service
  • More than one api_key for a service
  • An OAuth app with no covers
  • A permission in covers that the service’s catalogue does not have
  • An events block on a service with no webhook transport
  • Invalid JSON, or a file you configured that is not there

Apps a node ships

A node’s own metadata.json can declare accessApps for public applications with no secret. Those need PKCE, and metadata carrying a client_secret is refused: node metadata is source, and source never holds secrets.

An application with a secret belongs in this file.

Encryption

Everything stored is sealed under CREDENTIAL_ENCRYPTION_KEY, which is 32 bytes in base64:

openssl rand -base64 32

Set it before you store a credential you care about. Without it weft uses a public development key from its own source and warns you once, which protects a database dump from nobody.

Keep it. There is no rotation: change the key and everything sealed under the old one is unreadable until you put it back. Lose it and you reconnect everything.

Using a connection in a node

Declare an Access input with the connect widget:

"inputs": [
  { "name": "account", "type": "Access", "required": true,
    "widget": { "kind": "access" },
    "requiresScopes": ["chat:write"] }
]

Write just {"kind": "access"}. The service and whether it is optional are filled in from your node’s service recipe.

Then open it:

let account: Access = ctx.inputs.get("account")?;
let client = ctx.open(&account).await?.client();

let reply = client.post("https://slack.com/api/chat.postMessage")
    .json(&body).send().await.node_err("posting the message")?;

That client already has the authentication, the routing and the metering on it.

Sending the call and reading the answer

Every API node does the same three things after building a request: send it, fail with the provider’s own words when the status is not a success, and read the body. weft::access::client has that once, so your node does not write it again:

CallUse it when
get_json(&client, url, what) / post_json(&client, url, &body, what)A plain GET, or a POST of a JSON body, that answers JSON
json_call(request, what)The request needs its own preparation (a content type, a multipart body) but answers JSON
checked_send(request, what)The answer is not JSON (a download, raw text). You get the response back unread
require_ok_flag(answer, "ok", "error", what)The service answers 200 to everything and says inside the body whether it worked (Slack’s ok, Telegram’s ok and description)
cursor_paged(paging, what, request, check, visit)A list that comes a page at a time with a cursor. CursorPaging says where the items and the next cursor sit and which query parameter carries it back; past_cap_hint is what the user is told to narrow when the list runs past the page cap
required_str(&answer, what, "id")A field the rest of the node cannot do without, failing by name when it is missing

what names the attempt, so a failure reads “the service answered 403 trying to post the message” instead of a bare status code.

If your node waits on a job the provider runs, go and read waiting on a job a service runs.

The one rule about HTTP

A call on a connection goes through ctx.open or ctx.client. Everything else goes through ctx.http().

A reqwest client you build yourself skips both the signing and the cost recording, and the call will either fail unauthenticated or succeed while nobody writes down what it cost.

CallGives you
ctx.open(&access).await?The connection, leased for this firing
ctx.client(access).await?Straight to the signed-in client. Takes None and gives a plain one
ctx.http()The shared plain client, no credentials

ctx.client accepting None is for a node whose connection is genuinely optional, like fetching a link that is usually public.

What an opened connection knows

CallGives you
client()The HTTP client, authenticated and metered
credential()The single credential, for a service that has one
value(name)One stored value, like imap_host
identity()Who it is, for a log line
owner()Whether it is theirs or the runtime’s

Keep those out of your outputs and out of your logs. Anything you emit is journaled and shows in the inspector.

Saying what you need

requiresScopes and requiresValues on your Access input are the important part, and they are per consuming node.

{ "name": "account", "type": "Access", "requiresScopes": ["channels:history"],
  "requiresValues": ["imap_host"] }

Those get stamped onto the value as it reaches you, and checked when you open it. So a node that needs to read history refuses a connection that can only write, before it makes the call, and the person is told to reconnect rather than getting a provider error they have to decode.

The list is all-of, and it is fixed by the node’s one job. When the permission would depend on which input arrives, like a post that needs one scope for a person and another for a company page, that is two nodes, each declaring its own. One node with the rule written in prose protects nobody.

A missing stored value always refuses. A missing permission only refuses when weft actually knows it is missing: on a service that reports nothing, the call goes through and the provider decides.

A long job

A lease covers the firing. For something that genuinely runs a long time, a multi-hour generation, ask for a window:

let conn = ctx.open_within(&account, Duration::from_secs(3 * 3600)).await?;

The default is fifteen minutes, which is generous for an API call including its stream and its cost resolution, and short enough that a lease left by a crashed worker goes stale the same quarter hour.

A connection your own node hands out

If your node runs the service, it publishes the connection rather than consuming one:

let access = ctx.publish_access(BTreeMap::from([
    ("host".into(), host), ("password".into(), password),
])).await?;
ctx.pulse_downstream(NodeOutput::new().set("access", access)).await

Declare it in metadata with "publishes": "postgres" and an Access output.

Publishing again updates rather than duplicating, and terminating the node’s infrastructure deletes it. ctx.published_access() reads back what you published before, which is how a container that mints its own password on first boot is asked for it exactly once.

In a test

let access = rig.access("slack");
rig.connection_value("slack", "team_id", "T123");
rig.connection_permissions("slack", ["chat:write"]);

let outcome = rig.run(&SendNode, json!({ "account": access, "text": "hi" })).await.ok()?;
rig.assert_sent("POST", "/api/chat.postMessage");

No real credential, no real call, no cost.

Declaring a service

A service recipe is the service block in an access node’s metadata.json. It is pure declaration: how a credential is obtained, how a request is signed, what the permissions are, how events arrive. There is no Rust per service.

The node itself is one line:

weft::access_node!(SlackAccessNode);

The top level

KeyDefaultWhat it says
servicerequiredThe name, lowercase letters, digits and underscores
labelthe service nameWhat people see, like “Slack”
acquisitionrequiredHow a credential is obtained
auth[]How a request through it is signed. Empty for something that signs nothing, like a database
doors["own"]Which connect doors to offer
testnoneA call made at connect time to check it works, and where identity usually comes from
identitynoneThe display name, assembled from stored values, like "{team} / {user}"
permissions[]The permission catalogue people tick from
all_permissions_urlnoneWhere the provider’s full list is, when yours is a subset
verificationsilent, freeWhat the check can learn and what it costs
connection_optionalfalseThe node runs with nothing picked
callback_httpsfalseThe provider refuses plain http callbacks
own_pagenoneWhat the “your own” door shows beyond its paste fields
events{}How this service reports events, by topic
verifynoneHow a caller presenting this connection is checked, on a gated route
capabilities[]Named groups of optional fields, each unlocking one thing
grantscoexistingWhether two grants of this service can coexist

Acquisition

kindForKeys
staticPaste a keyfields
oauth2Sign ingrant, token_url, scope_delimiter, auth_params, extra_params, registration_fields, captures, token_auth, refresh
mint_jwtSign your own assertionfields, algorithm, claims, jwt_ttl_secs, exchange

The grant is authorization_code with an auth_url and PKCE on by default, or client_credentials.

There is a fourth, runtime, and you never write it. It is the stored form of a shared connect, written by the store, and declaring one is refused.

Auth steps

How a request gets signed, applied in order:

kindWhat it does
headerAdds a header
queryAdds a query parameter
basicHTTP basic auth
path_prefixPuts a prefix on the path
base_urlReplaces the scheme, host and port, keeping the node’s path
signSigns the request: SigV4, or OAuth 1.0a

Every value is a template interpolating stored values by name, like "Bearer {token}".

A header step’s name can be a template too, for a connection where the person types which header their key goes in (a generic API key): { "kind": "header", "name": "{header}", "value": "{key}" }. It may only name a declared field that is not secret, and if what the person typed is not a legal header name, the call is refused before anything is sent, telling them to reconnect.

Permissions

{ "id": "chat:write", "label": "Send messages",
  "description": "Post messages as the app.", "default": true }

A curated catalogue, not the provider’s whole list. On a provider with hundreds of scopes, list the ones your nodes actually need and point all_permissions_url at the rest.

default: true starts it ticked. own_only: true means the runtime’s own credential can never serve it, which greys out the shared option for anyone who ticks it.

Verification

Say honestly what your check can learn:

rungMeaning
reports_permissionsThe provider states what it granted
self_introspectThe credential can describe itself
reports_validityOnly “alive”, never “what”
silentNothing knowable

Only the first two make the recorded permissions authoritative, and only those let weft block a node before it calls.

cost is free, ambiguous or paid, and only free runs on its own. A check that might bill is never run without being asked.

Today every service in the shipped catalog declares free, and nine of the thirteen declare reports_validity. So in practice the only way a connection is genuinely verified right now is the OAuth scope echo, where the provider tells weft what it granted. A probe rung exists in the type and nothing implements it.

Your own door

own_page is what that door shows:

KeyWhat it is
mintA button that creates the application for them, if the provider has an API for it
guideNumbered steps, and a pre-filled creation link
pasteThe fields, when the acquisition is not already a paste form

A paste block on a static acquisition is refused as redundant: those fields already render.

What it refuses

The validator is strict, and the messages name the fix. A service name with a capital in it. A header name that is not a legal header name. A capability listing a field the service does not declare, or listing a required field, since a required field can never be the thing that is missing. A permission with no description. A service that signs its requests offering the shared door. A token_echo caller check, which compares a token weft minted when subscribing and a route’s caller never has.

Events

Each topic under events says which facts to pull out of a payload, which account it belongs to, and how it arrives. Go and read events from a service.

Where it goes

A package root’s metadata.json, so every node in the package inherits it, or the access node’s own file.

Exactly one input in the node needs the access widget. That is the connect control, and a recipe without one is refused.

Events from a service

A trigger that waits for a Slack message, a new row in a sheet, a page changing in Notion. The service has something to tell you, and there are two roads it can travel.

The two roads

Dial out. weft makes an authenticated call that hands back a socket address, connects out, and holds the line. Nothing needs to reach your machine, so this works from a laptop behind a router.

Dial in. The service posts each event to an address you published. That needs a public address, and it is the only road a shared application can use, and the only road most services offer at all.

You never choose. You name a connection and a filter, and the transport follows from what the service declares and what your environment can serve. On a cloud install, if the service can push these events for your account, weft takes the push, so nothing has to stay running between events. Otherwise weft dials out when it can, because that needs nothing from you.

What a topic declares

A service declares a map of topics, because one provider can genuinely report along several independent channels at once.

KeyWhat it says
fieldsThe named facts of one event, and where each lives in the payload. A dotted path, or header:X-Some-Header
accountWhich stored value names the account on a connection, and where the same id sits on an event. That pair is what routes a shared application’s events to the right connection
socketPresent when the service offers dial out
webhookPresent when the service dials in

Everything downstream works on the names. Subscriptions filter on them and triggers fan them out, so neither a filter nor a trigger node ever writes a provider’s path.

Receiving a push

A webhook topic declares how the provider’s pushes are handled:

KeyWhat it is
verifyHow a push is proved real: an HMAC, an Ed25519 or ECDSA signature, an API key, OIDC, or a token weft minted
handshakeThe one-off proof of ownership: echo a field from the body, echo a query parameter, or call a URL back
event_pathWhere the event sits inside the envelope
route_byWhether the event names the provider account, or an id weft minted
decodeFor an event wrapped in base64 or a form encoding
subscribeThe calls that create, renew and cancel the subscription

The secret is never in the recipe. Every scheme is a protocol with holes, and the material comes from the operator’s application config, or from a token weft minted. That is what lets a recipe live in source.

What activation does

For a dial-in topic, the first subscribe happens then. So a missing public address or a provider refusing is that activation’s error, not a mystery three days later:

this trigger cannot be served on the 'slack' connection: the service declares
no transport for this topic

And the one you are most likely to meet:

this trigger needs 'slack' to deliver events TO your weft, which requires your
weft to be reachable from the internet, and it is not. If you accept making its
public trigger surface reachable, rerun ./setup.sh --public-url and re-activate;
or use a connection that supports dialing out (your own provider app), where
the service offers one.

A decision that a trigger cannot be served is final for that registration. Retrying would work out the same facts again. Reconnecting the account re-registers it and works them out afresh.

If a topic is not in the connection’s snapshot, the account predates it:

'slack' declares no event topic named 'reactions'; reconnect the account (an
older connection may predate the topic)

Renewal

Most providers expire a subscription. The recipe says the margin, and weft re-subscribes before the deadline.

A renewal is a fresh subscribe, with a new id and a new token, because the providers surveyed have no extend verb, and reusing the token would make the old and new channels impossible to tell apart.

If a topic declares no unsubscribe, deactivating cannot hurry it and the subscription lapses on its own expiry.

An application-wide subscription

Some apps want every install’s events rather than one account’s. That only works over dial out, because a push receiver routes each event to one account’s connections and could never deliver “every install of your app”.

an app-wide subscription only works over a dial-out transport, and this topic
declares none

Which triggers need a public address

Only the ones where the provider pushes. A timer, a poller, a socket weft dials out to and a form somebody fills in all work with nothing reachable from outside.

For the address itself, go and read a public address. For declaring a topic, go and read declaring a service.

Measuring what a call costs

A node never states a cost. It never totals anything, never carries a running figure between steps, and never works out whose spend a call was.

A meter does that, once per provider, wrapped around the client your node already uses. All the care lives in one place so a node author can be careless: a node cannot produce a wrong bill, because it is never asked.

That only holds if your calls go through ctx.open or ctx.client. A client you built yourself is outside the meter.

What a meter is

One implementation of ProviderMeter per service, registered with a macro at the bottom of its file. Its existence is the declaration that this service’s calls are measured. There is no flag anywhere else.

MethodRequiredWhat it does
service()YesThe service name, matching the access node’s
base_url()YesWhere the provider really lives. The single authority, so no caller ever takes a host from a request
classify(method, path)YesWhat kind of route this is
observe(path, query, body)YesA fresh tap for one response
resolve(path, observed, follow_up)YesPrice one observed call
prepare(path, body)NoRewrite an outgoing body so the cost comes back reportable
priceable(path, follow_up)NoAsked before sending: can this be priced at all when it returns?
opens_charge, charge_reported_on, fold_reportNoFor a provider that reports the cost later, on a separate response
observe_session(path, query)For a session routeA fresh tap for one long-lived session, priced by its frames

Routes

ClassMeaning
BillableSpends money. Prepared, observed, priced
FreeA known route that costs nothing: a model list, a health check, a cost lookup
ReportsNot a charge itself. It speaks for one that is already open
BillableSessionA session priced by its own frames as they flow
UnknownNot a route this meter knows

Free is why a node asking what something cost is harmless, and why the meter’s own follow-up query does not bill.

Route matching is a security boundary

classify gets the path relative to the base URL, with no leading slash and no query, and matches it exactly.

Anything that does not match lands in Unknown. That is deliberate: a traversal, an encoded traversal, a backslash or a host smuggled into the userinfo all simply fail to match, rather than sliding into a cheaper route’s price.

What gets recorded

MeasuredCost carries an amount, a model and some details.

The amount is optional, and None means genuinely unknown. It is recorded as unknown, and never booked as zero.

That distinction is the whole discipline. None is for a figure the provider will not give you. It is not for one nobody went looking for, and it is certainly not a licence to re-derive the quantity from the request: a token count you worked out yourself is unmetered spend wearing the shape of a measurement.

resolve never errors, for the same reason. An unresolvable cost is an honest unknown, not a failed call.

What the meter never touches

A credential. It gets a signed side-channel for its own follow-up queries and nothing else, which is what lets one meter work identically on a pasted key and on a sign-in.

Each record also says whose credential paid. For what each value means and how to read one instance’s costs, go and read who paid for a call.

Where the number surfaces

You see the number in the inspector beside the firing’s duration, with whose key paid next to it. It also appears in the run’s events as soon as the call happens.

This is weft’s own record of what it observed, not the provider’s bill. They should agree, and when they do not, the provider is right.

Writing one

Copy the nearest existing meter. What it has to get right:

Say the base URL once and never take a host from anywhere else. Classify exactly, and let anything unrecognised be Unknown. Tap the response as the bytes flow rather than buffering it. Return None when the provider will not tell you, rather than guessing. And if a route could never be priced when it comes back, say so in priceable before the call, so the caller finds out before spending rather than after.

The CLI

Every command, grouped by what you are trying to do.

Three flags work everywhere. --on <target> acts on an install named in weft.toml instead of the one on your machine (deploying to your cloud). If you want to reach a runtime by its address rather than a target name, pass --dispatcher <url> or set WEFT_DISPATCHER_URL; you cannot combine it with --on. If you are scripting weft, pass --json for machine-readable output.

--json changes behaviour, not just formatting, in three places:

  • weft run --json also detaches, so it starts the run and returns.
  • weft activate --json refuses rather than asking, when the project has preserved state waiting.
  • weft connect --json only works with --list, --grant, --disconnect and --forget. The walkthrough prints for a person.

Tab completes commands and flags in a new terminal after ./setup.sh. For zsh and bash, setup adds a short block to ~/.zshrc or ~/.bashrc, between two # weft tab completion marker lines, and ./setup.sh --uninstall takes it out again. For fish it writes ~/.config/fish/completions/weft.fish. Every Tab asks the installed weft for the candidates, so new commands and flags show up as soon as you update it.

Start a project

CommandWhat it does
weft new <name> [--assistant <who>] [--remember] [--ci <cloud>]Scaffolds weft.toml, src/main.weft, nodes/, a git repo and a .gitignore. --assistant copies Tangle into this project only, and is repeatable. --remember also makes that choice the default for every later weft new run without --assistant; --assistant none --remember clears the default, and --assistant none alone installs nothing and leaves it as it was. --ci gcp also writes the deploy workflow. If any step fails, nothing is written
weft catalog updateRe-copies nodes/base_catalog/ from the installed weft. Replaces the folder, so your edits in there go
weft tangle update [--assistant <who>]Re-copies Tangle. Replaces every file the template owns; anything your assistant wrote beside them stays

Run something

CommandWhat it does
weft runCompiles, registers, records a version, starts a run, and follows it
weft run <example>The same, with a saved example’s starting values
weft buildCompiles and registers, starting nothing. Also how you put back code the dispatcher no longer holds
weft bakePrepares every trigger’s settings and starts no listeners. Takes the same --running-policy and --drain-timeout as weft activate, for the same reason: the setup runs on a worker, and one still up from an older build is replaced first
weft stop <execution-id>Cancels a run
weft wake <execution-id> <node>Resolves a pure time wait now. Refused for a wait that expects a value
weft follow <project>Streams a project’s events live

Flags on weft run:

FlagWhat it does
--detachStart and return rather than following
--referencedCompile only the node types the graph uses, rather than the whole catalog
--target <node>Run this node and what it needs. Repeatable
--before <node>Run what it needs, and not it. Repeatable
--from <node>=<json>Start here, with these values as backups. A real producer still wins
--emit <node>=<json>Stand in for a node: say what it would have emitted, and do not run it
--group <group>=<json>Run one group, or one included file, on its own, with the values you hand it. A port a step inside needs that you do not hand is refused before the run starts
--feed <start>Also run what feeds this start: for each input you did not hand a value, the node that feeds it (through any group or include doors on the way) and nothing above it. Names a --from or --group start. Repeatable
--fire <trigger>=<json>Fire exactly one trigger with this event, using its bake. One per run
--seedReuse everything from head’s run whose slice of the program did not change
--seed-until <node> / --seed-before <node>Where reuse stops. Both need --seed
--rootStart a new version tree, parented on nothing
--save <name>Write these settings to examples/<name>.json before running
--clear <field>Clear a saved setting before applying flags: from, emit, target, before, group, feed, fire, instance, keeping (what --durable or --fast saved), keep_for, hold_secs
--durableIf the run has to carry on after its worker dies: another worker picks it up where it stopped, and a step caught halfway is failed rather than run twice. For what that costs in speed,, go and read when a run waits for its writes
--fastIf the run fires a durable trigger and you want it fast anyway: it lives in its worker’s memory and ends if that worker dies
--keep-for <duration>If you want this run kept longer or shorter after it ends than its trigger or project says: 30m, 12h, 7d, or forever. For the project and trigger settings, go and read how long a run is kept
--hold-secs <seconds>If a wait in this run should hold longer or shorter than its trigger’s holdSecs while the run cannot pause (a bus between its nodes is open): 0 fails such a wait at once, at most 30 days. For when a run cannot pause, go and read triggers and routes
--instance <id>Run for this instance of the program. Needed when the run reaches a step that exists once per instance (see programs with instances)

A run that fires a trigger (--fire) follows the trigger’s durable, keepRunsFor and holdSecs, and --durable, --fast, --keep-for and --hold-secs override them for that run; a run that fires nothing is fast unless a flag says otherwise. A run you start by hand is always recorded, even when its trigger has recorded off, so you can always find it in weft executions. For what each of these does when you set it on a trigger, go and read how a run is kept.

If you want to hand a run a file its real source cannot give you here (a WhatsApp voice note needs a paired phone), write it in any --from, --emit, --group or --fire value, or in a saved example, the way you would write it in source. "@asset(\"samples/hello.ogg\", Audio)" uploads the file into the project’s storage and hands the port the file value its type names (Image, Video, Audio or Blob); "@file(\"prompts/x.md\")" hands it the file’s text. Both mean exactly what they mean in a .weft (see files and reuse), and a path is relative to the project root:

weft run --emit receive='{"messageType":"audio","file":"@asset(\"samples/hello.ogg\", Audio)","seconds":3}' --target transcribe

A missing file stops the run before it starts, and so does a file on a port whose type does not take one. A saved example keeps the marker, so each run reads the file again.

Look at a run

CommandWhat it does
weft executionsPast runs, newest first. --limit (50), --offset, --project, --phase, --node (the node that started the run), --since 2h, --status, --instance, --tag. --through <node> keeps the runs in which that node fired, wherever it sits. --search <words> keeps the finished runs that carried every one of those words somewhere in what they recorded (the trigger’s input, what a node sent on, an error, a log line): an email, an order id, a phrase in quotes. --through and --search only find recorded runs that have ended: a run shows up in them a few seconds after it ends, or longer while the install is busy. On a cloud install whose dispatcher had scaled to zero, the runs that ended meanwhile are found a few seconds after it wakes, so the first search after a quiet stretch can miss them. In the plain list, a run that a trigger with recorded: false started shows only if weft wrote it down after all (for when, go and read how a run is kept), and --through and --search never find it. A run you start by hand is always recorded, so it always shows
weft events <execution-id>One run’s events in order. --node, --kind, --full for whole values. If you want one time round a loop, --iteration 3 keeps the fourth (they count from 0), and 3.0 the first time round a loop inside it
weft logs [<execution-id>]What the nodes wrote, plus every failure. A run that wrote nothing lists what it skipped and why (under skipped with --json). --limit
weft statusThe cwd project: registration, listener, every infra node the program declares (one never started says not started, a @per_instance one how many instances have a copy), each trigger and the version of the source it fires (with the events an instance’s trigger holds until a field is filled), recent runs, how many runs each trigger started in the last minute or two and how many of those failed (for a trigger with recorded: false, often the only sign its runs happen), what drifted, and what you can do next. It also shows the project’s own address (its routes at the root), or why that address is unavailable. While the install builds, it names each image building and where its log is; while infra starts, how long it has been going and what it waits on
weft psEvery project the dispatcher knows

--phase fire hides the setup runs that an activate or a resync makes, so it answers “has my trigger fired since I changed it”.

Versions and examples

CommandWhat it does
weft treeThe version tree, with runs beneath each version and head marked
weft checkpoint [<label>]Records the files as a version. No run, no build
weft branch <ref> [--discard]Restores a version’s files and moves head. Refuses if you have uncommitted changes, unless --discard
weft diff <left> <right>Compares what two runs put on their wires. A ref is an execution id or example:<name>. --full for every wire
weft freeze <name> [<execution-id>]Writes examples/<name>.json from a run: its parameters, the answers people gave, and the outputs you accept. --expect <node> emphasises a node in later diffs
weft examplesSaved examples, whether each is frozen, and its latest run
weft prune <version>Deletes a version, everything under it, and their runs

Triggers

CommandWhat it does
weft activateSets up every shared trigger and starts the listeners, and prints the project’s own address, where its routes answer at the root (on your machine a local port, on a cloud install its Cloud Run address). On your machine, if you want that address on a port of your choosing, --port <n> opens it there and the project keeps it for later activates. If another program or another of your projects holds that port, the command fails and nothing is activated. Without --port, the project keeps the port it has, or takes a free one when it has none or another program took it. Builds and registers first if it has to. A trigger that runs once per instance is left off and named in a warning, with the way to switch it on: --instance <id>, or an ActivateInstanceTriggers node in the program. It refuses while a connection the program needs is not picked on this install, naming each step, and while infra a trigger reads is not running (it never starts infra: weft infra start does). A worker still up from an older build is replaced on the way: --running-policy cancel (the default) cancels what it runs, wait lets that land first, up to --drain-timeout. If some triggers are on and others off (an infra stop took down the ones reading it), it turns on the ones that are off and leaves the rest as they are; if every trigger is on and you changed the source, it tells you to run weft resync
weft deactivateStops the program’s own triggers listening, and tells you how many instances still have theirs on. --instance <id> stops one instance’s, --all-instances stops every instance’s and leaves the program’s own alone
weft resyncDeactivate and re-activate in one shot against your current program. With no flag it does every trigger that is on, the program’s own and every instance’s, and prints whose it did. It only touches triggers that are on; for ones that are off, use weft activate
weft cancel-activateCancels an activate in flight
weft cancel-buildCancels a build in flight. The install stops the build, and the command that was building fails saying it was cancelled
weft cancel-runningEnds a drain early while a deactivate is waiting

deactivate, resync and the infra verbs share four flags. activate and infra start take the last two:

FlagWhat it does
--mode wipe|hibernate|parkWhat happens to work that arrives while the triggers are down. park keeps all of it and runs it once they are back, hibernate does that for a grace window, wipe drops it (the full rules)
--grace <minutes>How long hibernate keeps taking work. 15 by default
--running-policy cancel|waitCancel the running executions, or wait for them. cancel unless you say otherwise; wipe refuses wait. On weft infra stop and terminate it works on an inactive project too: no triggers to take down, but a run may still be using the infra
--drain-timeout <seconds>Cap on a wait, so only beside --running-policy wait; passed with cancel it is refused. 60 by default. What is still running at the cap is cancelled

Every trigger verb (activate, deactivate, resync, bake, cancel-activate, cancel-running) also takes which triggers it acts on:

FlagWhat it does
--trigger <name>Only this trigger. Repeat it for several. Without it, every shared trigger (a plain resync does every instance’s too)
--instance <id>That instance’s copies of the triggers that exist once per instance (see programs with instances)
--all-instancesOn weft deactivate only: every instance whose triggers are on, each with the same --mode. The program’s own triggers stay as they are

Infrastructure

CommandWhat it does
weft infra startBrings up whatever is down, and turns back on the triggers that came down with it (never one you switched off yourself)
weft infra stopScales to nothing, keeping the disk. The project’s running executions are cancelled first unless you pass --running-policy wait, because they may be using this infra
weft infra terminateDeletes it, disk included, unless the node asked for the disk to be kept. Asks first, and off a terminal or with --json it needs --yes. The same running-policy rule as stop
weft infra upgradeRebuilds against your current source; the triggers it took down come back once the new copies are up. When a build changes an infra node’s image, every command that builds says so: a copy started from then on gets the new image, and one already running keeps its own until this
weft infra statusWhere each copy that exists stands, with its address. A start or stop on its way shows as provisioning or stopping at once
weft infra list-doorsWhich pieces you can reach from this machine, and at what address
weft infra logs [<node>]What the containers printed. --tail (200), -f
weft infra cancelStops waiting on work in flight. Halts between steps rather than undoing
weft infra node-stop <node> [--force]One piece. --force takes down units that would normally stay up. Cancels every running execution of the project unless you pass --running-policy wait, since nothing records which of them use this piece
weft infra node-terminate <node>One piece, deleted. Asks first like terminate, and needs --yes off a terminal or with --json. The same running-policy rule
weft infra show <node>The node’s card: every item (label, kind, value) and every button, with the action to hand press and what it asks before pressing. A secret item’s value is never printed, --json included
weft infra press <node> <action>Presses the card’s button with that action, without asking: naming the action is the choice. Prints what the node answered, and the button’s warning if it has one
weft infra rebake <node>If an infra node’s baked outputs no longer match the real thing (say its password was reset outside weft), this applies the node again, leaving alone anything already running the way the node asks, and saves its baked outputs anew. --instance <id> rebakes one instance’s copy of a @per_instance node. If a weft infra start, upgrade or another rebake is already working on the same copies (the shared ones, or that instance’s), it waits for that to finish first
weft infra env <node> --into <file> --set NAME=Label ...Writes what the node’s card shows into an env file, every name in one go: --set DATABASE_USER=User --set DATABASE_PASSWORD=Password takes each item by its label as show prints it, and --as <NAME> is the short form for the card’s only secret. A secret’s value is never printed; the other values are, in the line confirming what it wrote. A new file is yours only; an existing one gets those lines set and keeps the rest. If a secret was handed over already, it refuses, quoting what the card says there: press the card’s button with weft infra press, then run it again

If you want to act on one instance’s copies of the nodes marked @per_instance instead of the shared infra, start, stop, terminate, upgrade, node-stop, node-terminate, cancel, show, press and env all take --instance <id>.

Connections and tokens

CommandWhat it does
weft connectWalks you through connecting an account
weft connect --listPrints what is stored, changing nothing. Works outside a project
weft connect --node <id>Go straight to one access node
weft connect --grant <id>Pick a stored connection for the node
weft connect --disconnectClear the node’s pick, leaving the connection stored
weft connect --forget <id>Delete a stored connection for good
weft connect --instance <id>Act inside that instance of the program on an access node whose connection is @instance_filled: --list, --grant, --disconnect and connecting a new account all work on its connections and its value, exactly as its settings page would
weft instance-values --instance <id>Lists what the program asks that instance to fill and what it was given; --set node.field=value and --clear node.field (both repeatable) change it in one go, re-arming its live triggers that read a changed value
weft connect --door shared|ownWhich door to connect through, without being asked
weft connect --set NAME=VALUEFill a credential field. The value is visible to other processes, so prefer the prompt or --set-env NAME=ENV_VAR
weft token mintMint a signal token. --name, --projects, --tags, --display <node>, --displays, and --expires <duration> (30m, 12h, 7d; without it the token works until revoked). Printed once and never again. --operator mints an operator key instead, which administers the whole install and takes no scope
weft token mint --instance <id> --expires <duration>An instance token for the project you are in: it acts inside that one instance and nothing else. --expires (30m, 12h, 7d) is required
weft token lsThe tokens you have, by their recognizer prefix
weft token revoke <id>Revoke one
weft options <step> <field> [--search <text>]Lists the choices a field with a searchable list offers (a model, a spreadsheet, a channel), the same list the editor shows when you search that field, read through the connection picked for it. One id label line each; the id is what you write in the field
weft connect-libCopies weft’s connect library into the project’s frontend (front/src/lib/weft-connect, or the folder --into names), so its pages show the editor’s connection pickers and list fields, and each instance gets its own settings page. The folder is the library’s: each run replaces it, so run it again after updating weft and keep your own code outside it

Cloud installs

CommandWhat it does
weft target add <name> <url>Names an install in weft.toml. Replaces the address when the name exists
weft target listThe targets, and whether you are logged in to each
weft target remove <name>Takes one out of weft.toml
weft login <name>Stores your operator key for it, after checking the install accepts it. --key-stdin for a script
weft logout <name>Forgets your key for it
weft running-source <folder>Writes the program an install is running into a new folder, without touching your own files. Add --on <target> to get what a cloud install runs
weft ci add --cloud gcpWrites .github/workflows/deploy.yml. Refuses to replace one somebody edited
weft target export <name> [--github] [--frontend <name>]Mints a CI operator key on the install, and gives the frontend the install hosts for this repository a new token along with its service’s name (the old token works until the workflow’s next run deploys the new one and retires it). With --github it sets them, and the workflow’s other settings, on the repository through gh, and lists what it set; without it, it prints the variables and writes the secrets to a file only you can read, under the install’s exports/ folder
weft target show <name>Where a target’s install lives: its address, and on a cloud the project and region it runs in, and the weft it runs
weft frontend add <name> [--repo owner/name]Makes one of the project’s frontends. With --repo (read through gh), a cloud install makes it a Cloud Run service and lets that repository deploy to it, and its token comes from the deploy workflow (weft target export); without, it runs wherever you run it, and its token is written to a file only you can read
weft frontend lsThe project’s frontends, and where each runs
weft frontend rm <name> [--force]Removes one: its tokens stop working, and a service the install made for it is deleted. --force forgets it even when the service cannot be removed, naming what stays
weft frontend token <name> [--done <id>]A new token, beside the one it has, with its id; --done <id> once that token is in place retires every other token of the frontend
weft domain add <name>Makes a cloud install answer at a domain you own: prints the DNS record to set and waits until it points there. --for api serves the project’s routes there instead, --for frontend --to <address> its frontend. --no-wait returns after printing the record. The first domain makes a load balancer that is billed while any domain exists, so it needs --accept-cost (your own domain)
weft domain listEvery domain, with its DNS record
weft domain rm <name>Stops answering at it; the last one takes the load balancer down
weft workersThe project’s worker settings: copies kept warm, the most copies, how many calls Cloud Run sends one copy at once (concurrency), CPU, memory, how long a copy holds on to things its runs share, such as a database pool, after nothing uses them (shared_idle_seconds), how many calls and events one copy takes at once (max_runs_at_once) and how long a call waits when it is full (max_queue_wait_seconds; for both, go and read when a worker is full). It shows which the project sets and which come from the install. CPU and memory only apply on a cloud install, where an unset CPU means one CPU; on your machine no worker is capped
weft workers set --min-instances 1 ...Changes settings for this project. Calls after the change go to workers that have them. On a cloud install, a worker started before finishes what it runs and then stops. On your machine, the worker at the project’s own address is stopped and the new one takes its port. A run that can pause moves to the new worker, a run that cannot keeps running on the old one, and whatever is still running after 10 seconds is killed. For the full rule, go and read how a run is kept
weft workers reset [<setting>...]Puts settings back to the install’s values

Stored files

CommandWhat it does
weft files ls [<prefix>]Everything stored, grouped by space
weft files inspect <key>One file’s full metadata
weft files download <key> [-o <path>]Streams it out. A failed download never leaves a half file behind
weft files rm <key>Removes one file
weft files rm <space>/Removes a whole space, kept files included
weft files usageHow much is stored, and how many files

The daemon

CommandWhat it does
weft daemon startBrings the runtime to the state the code describes. Idempotent, so it is both first boot and refresh. restart is the same thing
weft daemon stopStops the runtime and the workers it started. The database, your infrastructure and your data stay
weft daemon statusWhether it is reachable, and the public address if one is open
weft daemon logsThe runtime’s log. --tail (100), -f
weft daemon removeTakes a named install (WEFT_INSTALL) off this machine, everything of it included. The default install is removed by ./setup.sh --uninstall

Flags on weft daemon start: --rebuild forces every shared image to rebuild. --public-url and --no-public-url open and close the public address.

Testing nodes

CommandWhat it does
weft test-node [<node or package>]Runs node tests. Without --tier, the basic and fake tiers, which need no install
weft test-node --tier liveCalls the real provider with a real credential, and can spend money
weft node-test-hash [<target>]The content hash naming a package’s test inputs

Other flags: --test <name> for one test, --key <service> and --connection <service>=<id> for live credentials, --parallel [N], and --yes to skip the live confirmation.

What the editor drives

CommandWhat it does
weft describe-nodes --listOne line per node type: its name, its tags, the first sentence of what it does. The cheap first look
weft describe-nodes --node <Type> --compactOne node’s wiring view: ports, types and rules, with the presentation stripped out. A long pick list shows its first eight entries and how many more there are; the plain --node <Type> has them all
weft parseParses source from stdin and prints the project, the catalog and the diagnostics as JSON. Lenient: unknown types become placeholders
weft validateThe same input, strict, including the runtime rules like missing credentials. Also warns about every folder under nodes/ it had to leave out, and why; that alone never fails it
weft parse-serverA long-lived parse server, one JSON request per line, catalog held warm

Cleaning up

CommandWhat it does
weft cleanDeletes runs older than --keep-days, 30 by default
weft clean <execution-id>Deletes one run
weft clean --allDeletes every run
weft clean --project <id>Deletes that project’s whole history
weft clean --instance <id>, --tag <tag>, --status <how>, --node <node>Deletes only the runs these name, across the projects you name (or every one). Naming an instance or a tag takes all their runs; add --keep-days to spare recent ones. A run still going is left to finish unless you pass --cancel-running
weft clean --imagesReclaims worker images nothing references. --all spans every project
weft clean --build-cacheDrops the docker build cache and the node-test cache. The next build compiles cold
weft rmUnregisters a project: signals wiped, runs cancelled and erased, infra terminated, stored data reclaimed
weft rm --journalErases its runs one by one before unregistering it; weft rm erases them on its own too
weft rm --localAlso wipes its build artifacts here
weft rm --allBoth of those

Everything that asks you a question

Every command runs without a terminal. When the deciding flag is missing, weft asks a person if there is one, and otherwise fails naming the flag rather than hanging on an answer that will never come.

CommandWhat it asksThe flag that answers it
weft pruneConfirm the count of versions and runs--yes
weft rmConfirm what will be removed--yes
weft cleanConfirm the runs to delete--yes
weft files rmConfirm, naming how many files are kept ones--yes
weft connect --forgetConfirm--yes
weft infra terminate, weft infra node-terminateConfirm what will be deleted--yes
weft connectWhich node, which door, which app, which permissions, the credential fields--node, --door, --shared-app, --permissions, --set
weft activateWhat to do with preserved state--reactivate-choice
weft deactivate, weft resync, the infra verbsWhich preservation mode, and the hibernate grace--mode, --grace
weft test-node --tier liveConfirm that this spends money--yes

One asymmetry worth knowing: deactivate and resync are the only ones that pick a default off a terminal rather than refusing. They pick wipe, with running executions cancelled. The infra verbs ask only when a trigger reading that infra is on (the program’s or an instance’s), and off a terminal, or with --json, they stop and name the flags to pass, for example --mode park --running-policy wait. If you pass --mode or --grace up front, it is used when needed and ignored otherwise.

Everything destructive, and what it takes

CommandWhat goes
weft rmThe project’s registration, its signals, its runs, its infra including disks, its stored data
weft rm --journalThe same runs, erased before the registration goes
weft rm --localAlso .weft/target/ and its slice of the node-test cache
weft prune <version>That version, every version under it, and every run beneath them
weft clean <execution-id>One run, for good
weft clean --allEvery run
weft clean --project <id>That project’s entire history
weft clean --build-cacheThe docker build cache and the node-test cache
weft files rm <space>/A whole space, including files you marked to keep
weft deactivate --mode wipeEvery waiting signal, and suspended runs are cancelled
weft activate --reactivate-choice wipe_allEvery parked signal and every pending suspension
weft branch --discardYour uncommitted changes
weft catalog updateAnything you edited under nodes/base_catalog/
weft tangle updateYour edits to the Tangle files the template owns
weft freeze <name>The previous examples/<name>.json, replaced whole
weft infra terminateEvery infra resource, disks included, unless the node preserves them
weft connect --forget <id>A stored connection. Other projects using it then fail at run time asking for a reconnect

Worker images are shared between projects, so no level of weft rm reclaims them. weft clean --images is what does, and weft clean --images --all also removes every older builder base, runtime and standard worker image left by earlier weft versions. You only need it by hand for those: the runtime already reclaims the images no project uses when it starts and every six hours. A local install also holds the docker build cache to 20 GB at the same times (and ./setup.sh does on every install), dropping what was used least recently first.

Starting and stopping things

Every verb for the three lifecycles, what it leaves behind, and what to run after you change something. For why they are three separate things, go and read the three lifecycles.

Infrastructure

A program has infrastructure when one of its steps needs a container of its own, which you can see in the source because the step says so:

pg = PostgresDatabase { database: "imagebot" }

Nothing starts it for you. Not weft run, not weft build, not activation.

CommandWhat it does
weft infra startBrings up whatever is down and leaves whatever is already up alone
weft infra stopScales to nothing and keeps the disk. Start it again and your rows are there
weft infra terminateDeletes it, disk included, unless the step asked for the disk to be kept
weft infra upgradeRebuilds against your current source. The triggers it took down come back once the new infrastructure is up
weft infra statusWhere each piece stands, with its address
weft infra list-doorsThe addresses this project’s infra answers on
weft infra logs <node>What the containers actually printed
weft infra cancelStops waiting on work in flight
weft infra node-stop <node> / node-terminate <node>The same, for one piece

weft infra status prints one line per piece:

infra for imagebot (a1b2…):
  pg [running] -> postgres://…
StatusWhat it means
provisioningComing up
runningEvery unit is ready
stoppedNothing running, disks kept
flakyIt was healthy and has dropped below its readiness threshold
failedThe last apply failed. The message says at which stage

A project-wide partial is a real steady state rather than something in transit: some pieces are up and some are not.

When it hangs

weft puts no time limit on a container coming up, because a model server pulling weights can take a long time. So a bad image or a probe that never passes looks like waiting, not like an error.

Read weft infra logs <node> first, because that is where a service says what went wrong. Then weft infra cancel. Cancel halts between steps rather than undoing what it did, so the piece is left partly applied and marked failed; check weft infra status and either terminate it or run the verb again.

Infra you deleted from the source

Deleting the step does not delete the container. weft status warns you that live infrastructure exists whose node is gone from the source, and it keeps running, and costing, until you stop or terminate it. The verbs stay available for exactly that reason.

Triggers

weft activate runs each trigger’s setup once, then leaves the listeners running. Setup is not bookkeeping: it registers subscriptions with providers and dials sockets, and it fails loudly if it cannot, rather than minting a trigger that looks live and is dead.

What you wantRun
My program should answer the world nowweft activate
Stop it answeringweft deactivate
I edited something a trigger reads, and it is liveweft resync
Prepare a trigger without exposing it yetweft bake
Try one trigger with an event I made upweft run --fire <trigger>='<json>'
I clicked activate and want it backweft cancel-activate

Project status moves registered → activating → active → deactivating → inactive.

One trigger at a time

Each trigger is turned on separately, so weft activate --trigger inbound turns on one and leaves the rest alone; for the other flags, go and read the CLI. The project shows active while any of its shared triggers listens and none is part way through turning on or off, and weft status lists each trigger’s own state under triggers:.

If you stopped infrastructure that some triggers read, those triggers came down with it and the rest stayed on. When weft infra start (or the program’s own ctx.infra(..).start()) has the infrastructure up again, the triggers that came down with it come back on by themselves; one you switched off yourself since stays off. A plain weft activate turns back on whatever is still off. While a route’s trigger is parked, its callers are answered 503 with a Retry-After at once, so a client that retries gets through once it is back; the events a trigger picks up itself wait and run once it is back.

A trigger that reads an instance’s copy or an instance’s value (@per_instance, @instance_filled) exists once per instance, and its copies are turned on with --instance <id>. For those, go and read programs with instances.

Frozen inputs

Setup snapshots the trigger’s input values along with the registration, and a fire replays that snapshot. So a trigger’s inputs are whatever they were when you last ran setup. Change one and weft tells you the bake is stale:

the code changed since trigger 'inbound' was last prepared, so its bake is stale.
Prepare it again: `weft bake` does it without listening, `weft activate` does it and listens

Choosing what happens to work in flight

weft deactivate and weft resync both ask. If you want nothing lost while the triggers are down, park them:

ModeWork that arrives meanwhileWhat a person sees
parkNothing is lost. A provider’s push (a Slack message, a new email) or a submitted form, an answer to a waiting run, and every fire a trigger makes by itself (a schedule’s tick, a message on a connection it holds) wait, and run once the triggers are back, on the version they come back with. A call to a route is not held, even a webhook sent to one: it gets a 503 with a Retry-After, so a client that retries gets through once the triggers are backQuestions stay listed for the people who answer them, and an answer sent now waits too
hibernateThe same as park for the grace window (--grace, in minutes). After it, nothing new is taken and the triggers stop listeningQuestions are hidden for the whole time; someone who already has one open can still send their answer within the window
wipeRefused. Everything waiting is dropped and the runs waiting on the triggers are cancelled: a clean slate, and what you want while buildingQuestions are deleted

A schedule parked for two hours has two hours of ticks waiting when it comes back; the reactivate choice below decides whether they run.

With no terminal attached, or with --json, the default is wipe. Running executions are cancelled unless you pass --running-policy wait with --mode park or --mode hibernate (a wipe cancels everything, so it refuses wait). That lets them land first, capped at a minute (--drain-timeout), and weft cancel-running ends that wait early. The wait covers every run the triggers started that is still going, fast ones included, even one that has not written anything yet. A run parked on a timer or a form is not waited for: the mode you picked decides what happens to it. Whatever is still going at the cap is cancelled, except a recorded: false run that has written nothing: the cancel cannot reach it, so it finishes on its own.

The infra verbs that take triggers down (weft infra stop, terminate, upgrade) ask you the same thing, and their answer means the same. They ask only when a trigger reading that infrastructure is on, the program’s own or an instance’s. With no terminal, or with --json, they do not pick for you: they stop and name the flags, for example --mode park --running-policy wait, and if you passed those flags already they are used. When no trigger reading it is on, nothing is asked, but a run may still be using that infrastructure: --running-policy wait on its own lets it land before the containers go.

weft activate and weft bake make the same choice about a worker still up from an older build: by default what it runs is cancelled and it is replaced now; with --running-policy wait its executions land first. While it waits, nothing new is sent to that worker, and once its last execution lands the worker finishes writing down what it owes (a metered call’s cost) and leaves on its own, which takes a second or two; the cap covers both.

Reactivating a project that kept state asks you again: drain the parked work and keep the suspensions, keep the suspensions only, or wipe everything.

Infra has to be up first

A trigger that reads its address off one of your containers cannot be set up until that container answers:

these triggers' infra is not running: pg. Start it with `weft infra start`, then run this again.

Infrastructure that only a run touches does not hold activation back.

Executions

It starts fromCommand
Youweft run
One trigger, by handweft run --fire <trigger>='<json>'
A trigger firing for realNothing. It happens because you activated
A caller connectingNothing. The project has to be active
StatusWhat it means
runningWork is happening now
waiting_for_inputParked on a person or a service. No worker is up and nothing is being spent
completedFinished
failedA step failed (a wait the run held past its holdSecs and its node did not handle counts as one), or the run could get no further
cancelledYou or another run stopped it; its live caller hung up; it was a fast run and its worker went away; its worker was stopped while it could not pause (recorded: false, a caller still on the line with outlivesCaller off, or a bus between its nodes open); it reached the hour a cloud install allows one stretch of a run; or the runtime stopped it (a newer build replaced its image)

weft executions --status running lists both the ones working and the ones waiting, and the status column tells them apart.

Waiting, and stuck

Waiting means somebody still has to answer. Stuck means nobody can: steps are holding values that will never add up to enough to run them, and nothing is parked on a signal, so no answer exists that would move it along.

weft ends a stuck run as failed and names every step and what each is short of:

execution stuck: 2 firings hold pulses and can never fire: theirs has value,
still waiting on go; leaf has value, every wired input arrived

That is a bug in the shape of your graph rather than something to wait out.

Stopping one

weft stop <execution-id> cancels it. If a worker is up, it stops within about fifty milliseconds. If the run was parked, the cancellation is written down and the listener forgets its signals, so a late answer finds nothing.

Work already done stays done, and weft cannot take back a message, an email or a payment your program already sent.

Cancelling a run that already ended is refused rather than silently ignored, so weft stop on yesterday’s run tells you it has already finished.

After you change something

weft status compares your source against what is running and names the verb:

drift:
  infra: source has changed; `weft infra upgrade` rebuilds it
  binary: worker code has changed; the next run or `weft build` rebuilds the image
  definition: project shape has changed; the next run picks it up
  activation: the listeners fire an older program; `weft resync` re-registers them
What you changedWhat to run
The shape of the graphNothing. The next run picks it up
A node’s Rust codeNothing. The next run rebuilds it
What an infra step asks forweft infra upgrade, then weft activate
Anything a trigger reads, on a live projectweft resync

Until you resync, the subscription sitting at the provider is still pointed at the program you had before.

The journal

Every recorded run writes down what it did, event by event. For when each write happens, go and read when a run waits for its writes. That record is what the graph shows you, what weft events prints, and what a worker reads to pick up a durable run that was interrupted, or a run that was waiting.

weft events 9b81d0a2
weft events 9b81d0a2 --node classify --full
weft logs 9b81d0a2

Nothing edits an event once it is written. A run is deleted with its record once it has been kept long enough (go and read how long a run is kept), or sooner by weft clean, weft prune or weft rm.

What is in it

Each row holds one run’s events from one write, in the order they happened: which run, what kinds, when, and the events themselves. A worker sends the rows of several runs in one write. A run’s history is its rows, read in order.

The run’s life. It started, with the project, the entry node and a fingerprint of the exact program shape, so a resume runs against what it suspended on rather than whatever you have edited since. Then it completed, failed, or was cancelled, with the reason in words and in a form the inspector can read.

Each node’s life. Given its inputs (kicked), started, and then completed, failed, skipped, suspended, resumed or cancelled. A skip carries its reason in plain words.

Values on wires. PortEmitted is the only event that carries a value, and it carries it once. When a run is rebuilt, weft puts the value on every outgoing wire itself, which is why a replay lands on exactly the same picture as the live run did.

PortClosed is written only when a node closes a port on purpose. The ports that close because a body returned are worked out again on replay.

Loops, streams and buses get their own events: an iteration launching, a gather assembling, a bus participant joining, a window of messages.

What a call cost, from the meter, as it happens. A node can never write one of those.

Reading it back

When weft reads a run back (the fold), it takes the rows and the program and works out where the run had got to.

The journal holds only the facts learned from outside, and the fold recomputes everything else with the same functions the live engine ran, so a replay rebuilds exactly the picture the live run held. A replay shows the times the journal wrote down, so you see when each thing really happened.

When a row cannot be read

A row the fold cannot apply is a hole. It is logged, listed on the run, and the inspector shows how many there are.

In a run that has ended, a hole only spoils the replay view. In a run that has to resume, a hole stops the resume with an error, because a run rebuilt from part of its record could run steps that already ran.

The execution guarantee

A node’s completion is written after the node finishes, so a step whose worker died before that has no completion on record. For what weft does with such a step, in a fast run and in a durable one, go and read surviving a restart.

When a run waits for its writes

A worker gathers what its runs did and writes it a couple of milliseconds later, or at once when a run is waiting on it. Each run’s records always arrive in order.

A fast run (the default) waits for its writes only where something else is about to act on its record: when it pauses, when an answer it was waiting for arrives, when its worker is stopping and hands it to another one, and before it asks weft for something on its behalf (a connection or an endpoint it does not already hold, anything on its stored files, a tag through ctx.tag_execution, a stop through ctx.stop_tagged, a call on its own program such as starting its infra). If a step calls a paid service and that call reports a cost, the cost is written in the background, and the run hands over its ending only once that cost is written. Once it has handed its ending over, the run is gone from the worker’s memory straight away. The ending is written after every event the run handed over before it, so a run is never reported finished before the rest of its record.

A durable run also waits before each step of a node that is not pure, before an answer leaves for its caller, and for its ending. One fired by an event also waits for its start to be written before the event counts as delivered. When the answer is the last thing the run does, its ending goes in the same write, so the answer costs no extra wait. A durable route that reshapes its input with pure nodes and answers with Reply (pure too) waits once, for the answer and the ending together. For choosing between the two, go and read how a run is kept.

If the database falls behind and 64 MiB of events are already waiting on a worker, the next run with more to hand over waits until there is space, so nothing is dropped. For what that does to new calls, go and read when a worker is full.

Why a failed write stops the run

If a write fails (the broker refuses it, or a minute of sending it again gets no answer), the runs whose rows were in it end as failed. A write that got no answer is safe to send again, because the record takes a batch sent twice only once. A journal missing rows the worker thinks it wrote would rebuild a different run: a node whose start was lost but whose values landed would run again and spend twice.

How long a run is kept

An ended run is kept for a week, and then deleted with its record, its logs, its search entry and its tags. A run that has not ended (running, parked, waiting for a worker) is never deleted, however old. If you want another length:

  • for every run of a project, set it in weft.toml:

    [runs]
    keep_for = "30d"
    
  • for the runs of one trigger, set its keepRunsFor input (12h, 30d, forever);

  • for one run you start by hand, weft run --keep-for 2h.

A trigger’s setting beats the project’s, and --keep-for beats both. A run’s keep time is fixed when it starts, so a change only reaches runs that start afterwards. If you change a trigger’s keepRunsFor or the project’s keep_for, the trigger’s own runs pick up the change once you run weft resync, and a run you fire with weft run --fire once you run weft bake.

Clearing it

weft clean                    # ended runs that started more than 30 days ago
weft clean 9b81d0a2           # one run
weft clean --project <id>     # that project's whole history
weft clean --all              # everything

Removing a project with weft rm erases its runs with it.

A past run that shows nothing

The journal names the program each run used, by hash. If the dispatcher no longer holds that program, the run’s rows are still there but nothing can be drawn against them.

If your files are the same as when the run ran, weft build makes it readable again: it registers the program under its hash, and that is the hash the run names.

Versions, seeds and frozen examples

Every run records the code it ran, so you can go back and look, run something again against a change, and see what moved.

A version

A snapshot of your project’s files, taken whenever you run, activate, or type weft checkpoint.

In itNot in it
Every .weft file, at any depthAnything hidden at the top: .git, .env, .weft
weft.tomllayouts/, because dragging a box is not a change to your program
src/** and nodes/**, including a node that sits beside your code and the standard library in nodes/base_catalog/, edits and alltarget/ at the root, and node_modules anywhere
assets/**, prompts/**, scripts/**, sql/**front/, which ships through your project’s CI
examples/**, so going back restores the examples that existed then

A file identical to one an earlier version held costs nothing to store, so the standard library is stored once and every later version reuses it. An install builds a version from exactly the files it holds and nothing of its own: if you deleted nodes/base_catalog/, the version has no standard library.

head is where you are: the version your next checkpoint sits under, and the run a --seed inherits from.

CommandWhat it does
weft treeThe whole tree: every version with what changed against its parent, its runs beneath it, head marked
weft checkpoint [<label>]Records the files as a version. No run, no build
weft branch <version>Puts those files back and moves head
weft branch <execution-id>The same, and points head’s run at that one, so the next seed inherits from it

weft branch refuses on a dirty tree and names the files, rather than throwing your work away:

the tree has changes since head a1b2c3d:
  src/main.weft
`weft checkpoint` keeps them as a version, or `--discard` throws them away

Seeding

weft run --seed

Reuse everything from head’s run whose slice of the program did not change, and run only the rest. That is the loop when you are iterating on step nine of a twelve step program and steps one to eight cost real money.

What will not be reused

Why
Anything whose code or dependencies changedIt is a different thing now. weft says so by name
Anything downstream of something that changedIts input is different
Anything that never finished: failed, cancelled, still running, or parked on somebodyThere is no result to reuse
Anything that emitted a live handle, like a busThe handle belongs to a worker that is gone
A whole loop, if any iteration of it is invalidA loop is reused whole or not at all
Anything downstream of an explicit --emit or --fireYou asked for a fresh event, so its consumers are fresh
Anything past --seed-before or --seed-untilYou said where to stop
A starting value you supplied that differs from the one the earlier run usedDifferent input, different work

The invalidation spreads: if a step is not reusable, nothing downstream of it is either.

When a reused step’s file changed

A saved result that names a stored file says what the file held then. If the file has been edited since (a later step added to it, another run changed it) or is gone, the run is refused before anything starts:

--seed would reuse what 'ask' produced on 'historyFile', but the file it names,
'conversation.json', has changed since (that run left it at version 4, it is at
version 6 now), so the saved result no longer says what that step made. Hand
the file in yourself (`--emit ask='{"historyFile": ...}'`), or run 'ask' again
by keeping it out of the seed (`--seed-before ask`).

The one that surprises people

A value you hand in at a start is a backup. It stands in only when nothing upstream supplies that port.

Under a seed the upstream result is usually right there in history, so your backup goes unused and the step is reused exactly as it was. The run reports success and nothing ran, which is correct and invisible, so weft says it out loud:

'classify.text' keeps the value run 3f2a1111 gave it; the value supplied at
this start is a backup and only stands in when nothing upstream supplies the
port. Run without --seed to hand it in.

Frozen examples

An example is a run you keep: its starting values, the answers people gave, and the outputs you accept as right.

weft run --from classify='{"text":"..."}' --save angry-customer
weft freeze angry-customer

--save writes the starting parameters. freeze takes a run that completed and records its accepted outputs alongside them.

Then, after a change:

weft run angry-customer
weft diff <new-execution-id> example:angry-customer

diff shows what moved on the wires. You, or Tangle, judge whether the change is acceptable. Freezing the new run replaces what is accepted.

--expect <node> marks the outputs that matter, so a later diff leads with them rather than burying them.

CommandWhat it does
weft examplesWhat is saved, which are frozen, and each one’s latest run
weft freeze <name> [<execution-id>]Freeze a run. Without an execution id, head’s run
weft diff <left> <right>Compare two runs. A side is an execution id or example:<name>
weft run <name>Run the current code with that example’s parameters

A freeze replaces the example whole, and says so. Only a completed run can be frozen: a failed or cancelled one has nothing to accept.

Cleaning up

weft prune <version>

Deletes that version, every version under it, and every run beneath them.

It refuses on head’s version, on any version a frozen example came from, and while any run underneath is still going.

Files at run time

A value on a wire is capped at 100 KB, so anything bigger is stored and what travels is a marker saying where it is.

This page is about the files a running program leaves behind and what to do with them. For putting one there from a node, go and read storage.

Where they live

SpaceHoldsGone when
ExecutionWhat one run madeFive minutes after the run ends, so its output is still downloadable, unless something kept it
ProjectThings that outlive a runweft clean or weft rm
SharedThings several projects meet in, by nameYou remove them
AssetsYour @asset copiesThey follow your source

The five minute window matters. A run that makes a picture and ends has not thrown it away yet, and a link you handed somebody still works for a little while.

Keeping something

A node stores a file with a keep policy, and that is what saves it from the sweep: thirty days by default, a span you choose, or forever.

Reading a file postpones its expiry, so one that is still in use stays.

In a graph, the storage nodes TextToFile, FetchToStorage and KeepFile take the same two settings: scope (execution, this run only, or project, every later run of the project can read it) and ttl_days (how long it lives once nobody touches it, 0 for never expiring). Every other node that makes a file, a download from S3, Slack or Drive included, stores it for its run only. If you want such a file longer, or in a later run, wire it through KeepFile.

There is no un-keep. A file marked to survive survives until somebody removes it.

Looking at them

weft files ls
weft files ls project/
weft files inspect exec/9b81d0a2/chart.png
weft files usage

ls groups by space and shows the size, the filename, and whether a file is kept and when it expires. usage is the total.

Getting one out

weft files download exec/9b81d0a2/chart.png
weft files download exec/9b81d0a2/chart.png -o ~/Desktop/chart.png

It streams straight from storage into a temporary file and renames it only after the size checks out, so a download that fails never leaves you a plausible half file, and never overwrites a good copy with a broken one.

Removing them

weft files rm exec/9b81d0a2/chart.png
weft files rm project/

The second form removes a whole space, including files somebody deliberately kept. It tells you how many of those there are before it does anything:

About to remove the whole space 'project/': 48 file(s), 12 of them KEPT
(persisted on purpose).
Type 'yes' to confirm:

--yes skips the question, and is required when there is no terminal to ask.

Three kinds, for three audiences, and a node picks the right one:

  • a temporary signed link, about fifteen minutes by default
  • a link the open internet can fetch, when this install serves a public address
  • a link a caller of this install can fetch, which is what a route’s answer carries in place of a file

They all expire, which is why a stored file travels as a marker rather than a URL. The marker keeps working; a link does not.

When a file goes missing

If a download or a node says a key is not there, it was almost certainly swept: an execution-scoped file that nothing kept, past its window.

weft files ls tells you what is actually there. If a program’s output needs to outlive its run, the node that made it has to say so when it stores it.

The browser extension

Weft tasks is where a person answers a program. When a step asks a question, the run parks and the question shows up here as a card.

Install it from Firefox Add-ons or the Chrome Web Store. To build it from your checkout instead, ./setup.sh --browser --no-sign writes an unpacked build per browser under extension-browser/build/, and Chrome loads it from chrome://extensions with Developer mode on.

Point it at your runtime

The extension ships with no access to anything. Not your runtime, not any website. You give it one address at a time.

Mint a token from your project folder:

weft token mint --name "my laptop"

Then open the extension’s settings, add your runtime’s address and paste the token. The browser will ask whether to allow access to that host, because until you say yes the extension cannot reach it. Remove the last token for a host and it hands that permission back.

The token’s full value is printed once, at mint, and never again. weft token ls shows you which tokens exist by a short prefix, and weft token revoke <id> ends one.

Scoping a token

A token with no restrictions sees every waiting step in the tenant, and no node displays at all.

FlagWhat it narrows to
--projects <uuid>Only these projects. Repeatable
--tags <tag>Only steps carrying one of these tags. Repeatable
--display <node>Also lets it read one node’s display, and press the buttons on it
--displaysEvery node display in the token’s projects

The display scope is the one that grants nothing by default, because a display can be a credential: a bridge showing a QR code to scan, a database showing the password it just minted.

What you see

The popup is the list. It shows whether each runtime is reachable, and splits what is waiting into triggers, which start a new run, and questions from runs already going.

It distinguishes two kinds of failure, and says which: a host you have not granted, and a host it could not reach. A popup that said “Connected” while a runtime was down would be hiding tasks from you.

The task page is one card at a time, with the count, and Previous and Next to move. The card shows whatever the step declared: values to read, text boxes, option buttons, approve and reject pairs, images and files.

ButtonWhat it does
SubmitAnswers a question inside a run already going
FireStarts a new run, on a trigger
SkipAnswers with nothing and lets the run carry on. Not shown on a trigger, because skipping a trigger would fire it
Cancel runEnds the whole execution. Also not shown on a trigger

Approve and reject are labelled by whoever wrote the step, so they can read Send it and Hold it rather than the defaults.

If a program’s author gave you an instance token, Your connections (the link icon in the popup) is where you connect your own accounts. With the instance token added, it lists the steps that run on the instance’s own account, and what you connect and pick there belongs to that instance alone. If the page says you have no instance token, add the one the program’s author gave you; other tokens you have added are ignored there. For instance tokens and what they reach, go and read programs with instances.

After you submit, the card says Submitted for a moment and moves to the next. Back in the editor, the cyan ring on that box goes green and the rest of the program runs.

What it can see

It holds three permissions: storage for your tokens, notifications, and alarms for the poller behind them. No access to any site until you add a runtime, and the Firefox build declares that it collects nothing.

It talks to two addresses on your runtime: one that lists what is waiting, and one that answers. With an instance token it also talks to the instance’s connect page on the runtime. The token rides in the Authorization header rather than in the URL, so it does not land in anybody’s logs.

Writing your own instead

The extension is one consumer, not the only possible one. It reads the same two addresses any client can, and it renders a form from what the step declared rather than knowing anything about particular steps. A Slack bot or an internal page would work the same way against the same addresses.

If you want to build one, the token is the whole authentication story, and weft token mint --display <node> is how it gets to read what a node is showing.

Programs with instances

If you want part of one program to run as several separate copies, each with its own WhatsApp number, Slack account, spreadsheet or schedule, you mark what each copy gets for itself: a container of its own (@per_instance), or a value of its own (@instance_filled). Each of those copies is an instance of the program.

What an instance stands for is up to you. One per customer is the common case, but it can as well be one per session, or several per person (a customer with two shops, each with its own bridge). weft only sees the ids: which person owns which instances is your program’s own data, kept in its own database.

An instance is just an id you choose, such as user-42, session-9f3 or a shop number. You hand it to weft each time something happens for that instance: in --instance, in an instance token, or in a header from your own backend. weft keeps no list of instances, so there is nothing to register. Once you wipe everything weft holds for an id (see cleaning up after instances), that id is no longer an instance.

Say you built a WhatsApp assistant and a hundred customers want it. A WhatsApp bridge signs in to one phone: when it first starts, it shows a QR code on its display, and whoever scans that code with their phone ties the bridge to their number. So each customer needs a bridge of their own, and you give each one an instance:

bridge = BaileyBridge {
  @per_instance
}
receive = BaileyReceive { bridge: bridge.bridge }
# a real assistant wires an LLM's answer into `message`
reply = BaileySend {
  bridge: bridge.bridge
  to: receive.from
  message: "hi"
}

The @per_instance line gives each instance its own bridge container, called its copy of bridge. receive and reply read the bridge, so they are per instance too. A message arriving on instance user-42’s phone starts a run for user-42, and reply answers through user-42’s copy.

Bringing one instance up

For customer user-42, from the project’s folder:

  1. If you have not activated the program since you last changed it, run weft activate once: that builds the bridge’s image, which every instance’s copy starts from.
  2. weft infra start --instance user-42 starts their bridge.
  3. Wait until weft infra status shows that copy as running.
  4. weft activate --instance user-42 turns on its receive trigger.
  5. weft token mint --instance user-42 --expires 7d --display bridge mints an instance token that can read that bridge’s display.
  6. The customer adds the token to the weft browser extension, opens the bridge’s display, and scans the QR code with their phone.

If you want your own website to do steps 1 to 4 for a customer, a program can do each of them itself; for how, go and read the instance nodes.

What @per_instance marks

@per_instance goes on its own line inside the braces of an infra node (a node that runs its own container, like the bridge). Anywhere else the compiler refuses it as per-instance-ineligible.

If you work in the graph, you can set and remove the mark from a node’s right-click menu; for how, go and read the graph.

What an instance fills: @instance_filled

If each instance should get its own value for a field (a spreadsheet, a model, a schedule, a Slack account), write @instance_filled where the value would go:

google = GoogleAccess { account: @instance_filled }
read = GoogleSheetsRead {
  account: google.access
  spreadsheet: @instance_filled
  tab: @instance_filled("0")
}
digest = Cron { cron: @instance_filled("0 0 8 * * *") }

A field holds one of four things:

In the sourceWhat the field holds
a value ("0 0 3 * * *", a picked connection)that value, for everyone
nothingthe node’s default; if it has none, nothing when the field is optional, and a refused run when it is required
@instance_filledthe instance’s value; if none was given, the field acts as if nothing were written (the row above)
@instance_filled(<value>)the instance’s value; an instance with none gets <value>, even when the node has a default of its own
@instance_filled(@file("prompts/default.md"))the same, with the file’s content as the value an instance with none gets (@asset(...) works too)

On a connection field (account above), what you write decides whose key pays for an instance’s calls:

In the sourceWhose key an instance’s run uses
nothing (your pick on the install)yours, for every instance
account: @instance_filledthe instance’s own; an instance with no connection is refused, naming the field

Only you can put your key in an instance’s run, by leaving the field unmarked and picking your connection on the install. An instance token can never pick your connection, or the runtime’s shared key: connecting with the shared key through the instance door is refused, and a value saved for an instance’s connection field has to be a connection that instance owns in this program, whoever saves it. If your connection is one made with the runtime’s shared key, the instances it serves spend that key, and each of their cost records shows Platform as the payer (for reading those, go to who paid for a call).

A field with a wire into it already gets its value from the wire, so the compiler refuses @instance_filled there (instance-filled-wired). A group’s, a loop’s or an included file’s own port is refused too (instance-filled-boundary); put the mark on the node inside that reads the value. A key that is not one of the node’s inputs cannot take the mark either (instance-filled-not-an-input).

You do not mark the other steps: a step that reads an instance’s value or copy, directly or through steps before it, runs per instance too. Above, read needs Google through the instance’s connection, so it runs in that instance’s runs.

weft checks an instance’s value against the node’s own rules at three moments:

  • when the program compiles: a rule that only needs the field to have a value passes, since the value will be given later; a fallback is checked like any written value (@instance_filled("0 3 * * *") on a Cron is refused right away);
  • when the value is given: a value the node refuses is refused on the spot, with the node’s own message, and nothing is stored;
  • when a run starts: the instance’s values are read once, checked again (the program may have changed since), and carried by the run, so every step of it sees the same values even if one changes mid-run.

If an instance’s value changes and a live trigger of that instance reads it (the trigger’s own field, like digest.cron, or anything its setup goes through, like its connection), weft sets that trigger up again with the new value, and only then saves the change. An event arriving meanwhile waits and goes through afterwards, on the new value. If the new setup fails (the new account refuses the connection, say), the old value and the old trigger stay, and the change fails with the reason. If you change several fields in one go (one apply(), one --set list), each trigger is set up again only once.

Which instance a run is for

Every run is for one instance or for none. Whatever started the run decides, and nothing changes it afterwards:

What started the runWhich instance it is for
weft run --instance user-42user-42
An event arriving through an instance’s copy (its bridge, its trigger)That instance
A call to a Route with an instance token in the Weft-Instance-Token headerThe token’s instance
A call to a gated Route with Weft-Instance: user-42user-42
A timer, a webhook, an open Route, anything elseNone

A gated route is one whose auth input is wired from an auth node such as ApiKeyAuth (weft’s messages call it a route gated by a connection), so the caller must present a key before a run starts. If your own backend calls your program for an instance, this is the route it uses. It is also the only place weft honours the Weft-Instance header: on an open route anybody could claim any instance, so weft refuses the header there and says why. A browser presents an instance token instead.

If you call a route and want the run to be for an instance, call it at one of its route addresses: /connect/local/<path> on the install, the project’s own port on your machine, or an API domain (for where those come from, go and read answering on a URL). A webhook also answers at a plain address without /connect, but a run started there is never for an instance, and weft refuses an instance header sent there.

What is checked before a run starts

weft refuses a run before it starts, and says why, when:

  • it reaches something per instance and is for no instance;
  • it reaches a field the node needs and the instance has not filled;
  • it reaches a value the instance has that the node’s rules refuse;
  • it reads an instance’s copy that is not running.

If an event reaches an instance’s trigger before what it needs is filled, weft holds it until the instance’s values change (from its settings page, SetInstanceValues or ctx.values()), then runs it; activating the trigger again also tries it. Nothing retries it on a timer, since only a new value can close the gap. weft status shows under the trigger how many events wait and which field they wait for, and so do ListInstances and ctx.instances().list(), so your website can tell the customer what is missing. An event that reaches the trigger while the instance’s copy is down is tried again on a timer (sooner at first, then every five minutes) until the copy is up. A call to a route gets the refusal instead.

An instance’s own infra

From the terminal, the verbs that start, stop, upgrade or remove infra take --instance:

weft infra start --instance user-42
weft infra stop --instance user-42                     # the container goes, its disk stays
weft infra node-terminate bridge --instance user-42 --yes    # the container goes, and its disk unless the node keeps it

weft infra status lists each instance’s copy on its own line. weft status lists them under instance infra:, and under infra: it lists every infra node your program declares: a @per_instance one says how many instances have a copy, and one that was never started says not started.

Every place that reports a copy’s state gives the same answer: weft status, weft infra status, ctx.infra(..).status() and the InstanceInfraStatus node. A start or a stop on its way reads provisioning or stopping from the moment it is asked for, before the container itself moves.

From your program, ctx.infra names the node and the instance:

ctx.infra("bridge").instance("user-42").start().await?;
let copy = ctx.infra("bridge").instance("user-42").status().await?; // None: no copy
let every = ctx.infra("bridge").copies().await?;                     // shared + each instance's

weft never starts an instance’s copy on its own: you do, from the terminal as above, or your program does. If you skipped step 0, the start fails and names weft activate.

start() returns once the copy is running, so the next line can activate the triggers that read it. A container can take minutes to come up, and the run waits that long without holding a worker: between looks at the copy it parks, the way a Wait node does. If the copy fails to come up, start() fails with the reason. If somebody stops or terminates the copy while start() waits (a customer pausing right after resuming, say), start() fails too, rather than starting it again behind their back.

An instance’s own triggers

A trigger that reads a per-instance node exists once per instance, and you turn each instance’s version of it on and off separately. weft activate with no flags turns on every shared trigger and skips these, because it cannot know which instances you mean:

weft activate --instance user-42
weft activate --trigger receive --instance user-42
weft deactivate --instance user-42 --mode park

weft status lists every trigger’s state under triggers:, an instance’s with the instance beside it. A trigger being turned on reads activating from the moment the activate is taken, even while weft is still moving the program’s workers. If you are scripting this, weft status --json has the same list under activations, each entry with its trigger, instance (absent for the program’s own), status and mode, plus waiting ({ fires, reason }) when the instance’s trigger holds events until a field is filled.

A plain weft deactivate turns off the program’s own triggers and leaves the instances’ on, and it tells you how many instances still have triggers on. If you want every instance’s off too, run it again with --all-instances:

weft deactivate --mode park
weft deactivate --all-instances --mode park

weft resync works the other way round: with no flag it brings every trigger that is on up to date with your program, the program’s own first and then each instance’s, and prints whose it did. --instance user-42 limits it to that instance.

From your program:

ctx.triggers().instance("user-42").activate().await?;

If you want to turn one off from your program, the call takes a few choices; for those, go and read taking something down from a run.

If an instance’s trigger reads its copy and that copy is not running, activating the trigger is refused, with the command that starts the copy.

If an instance’s trigger reads a value it fills (digest.cron, say), activating it before one was given is refused, naming the field, unless the field has a fallback. If you want an instance’s triggers on the moment it is created, give such fields a fallback: the trigger runs on the fallback until the instance’s own value is saved, and then weft restarts it with that one. Saving a value never turns on a trigger that is off.

Where an instance’s values are filled in

Each instance’s values are filled in on its settings page. It lists every field your program marks @instance_filled, grouped by step, each with the control the editor shows you: a connection picker for a connection, a searchable list for a list field (spreadsheets, say, read through the instance’s own connection), and a plain input for anything else.

If you want to show it to your customers, there are two places:

  • the browser extension’s Your settings page, for somebody who has added an instance token to the extension;
  • the InstanceSettings component, for your own website. weft connect-lib copies the library into your frontend (front/src/lib/weft-connect unless --into names another folder); for how to mount it, go and read the README it copies along.

The picker never offers an instance the shared key, because it spends your credits.

If you want to test that page from the terminal, two commands do what it does (each mints its own instance token that lasts one hour and revokes it when done, so the instance’s own tokens are untouched):

  • weft connect --instance user-42 --node openrouter --door own --set-env key=OPENROUTER_KEY connects an account as that instance’s from a key in your environment, and --grant <id> picks one it already has; --list and --disconnect work the same way;
  • weft instance-values --instance user-42 lists its fields and values, and --set read.spreadsheet=1AbC --clear digest.cron changes them. A value that reads as JSON (5, true, {"id": "..."}) is passed as JSON, so write a value made only of digits as 'read.tab="12"' to keep it text.

If your program should read or change an instance’s values, ctx.values() does it, with the same checks, and sets affected triggers up again:

let values = ctx.values().instance("user-42").get().await?; // by step, then field
let rearmed = ctx.values()
    .instance("user-42")
    .set("read", "spreadsheet", json!("1AbC..."))
    .set("google", "account", json!({ "id": connection_id }))
    .clear("digest", "cron")
    .apply()
    .await?; // returns the triggers it set up again
let theirs = ctx.connections().instance("user-42").list().await?;
ctx.connections().instance("user-42").forget().await?;    // the values naming them go too

Instance tokens

An instance token acts inside one instance of one program, and nothing else. With it, a browser or the extension can:

  • start that instance’s runs, through the program’s routes, in the Weft-Instance-Token header;
  • answer that instance’s waiting forms;
  • read the displays of that instance’s copies, such as its bridge’s QR code;
  • fill in that instance’s values, its connections among them.

An instance token always expires. If you mint one from the terminal (step 4 above), it reads a display only when you pass --display <node> for that node, or --displays for all of them. A token your program mints reads all of the instance’s displays and lives at most a year. If a customer just logged in to your site, this is how your program hands their browser a token for one of their instances (which instances are theirs is up to your own database):

let minted = ctx.tokens().mint_for_instance("user-42", Duration::from_secs(7 * 86400)).await?;
// minted.token goes to the browser, once
ctx.tokens().instance("user-42").revoke().await?;

The token comes out once, because weft keeps only a hash of it. So when a run is replayed after a restart, weft cannot hand back the token it minted the first time: it mints a fresh value for the same token, and the old value stops working. If your program already handed the first value to a browser, that browser needs the new one.

An instance’s files

StorageScope::instance() holds the files of the run’s own instance, and StorageScope::instance_of(id) those of any instance of this program:

ctx.storage(StorageScope::instance())
ctx.storage(StorageScope::instance_of(InstanceId::new("user-42")?))

The files stay until your program deletes them, you wipe the instance (see cleaning up after instances), or you weft rm the project. Using instance() in a run for no instance is an error. Another program never reaches these files, even for the same instance id.

Who paid for a call

Every cost record says whose credential paid: the runtime’s own key (Platform, when a step uses the shared key), yours (Author), or the instance’s own (Instance). ctx.costs() reads them, and you can narrow it by instance, node, service, run, who paid (paid_by) or since (unix seconds):

let spent = ctx.costs().instance("user-42").service("openrouter").since(month_start).list().await?;

Taking something down from a run

A program’s stop, terminate and deactivate calls each take a DeactivateSpec, the same choices weft deactivate asks you for:

let park = DeactivateSpec {
    mode: DeactivationMode::Park,          // or Hibernate, or Wipe
    grace_minutes: 0,                      // Hibernate's window before it wipes
    running_policy: RunningPolicy::Wait,   // or Cancel
    drain_timeout_secs: None,              // how long Wait waits; None is 60 seconds
};
ctx.trigger("receive").instance("user-42").deactivate(park, StopSelf::Keep).await?;

The mode decides what happens to the triggers. The running policy decides what happens to runs that were using the thing you took down: taking down an instance’s copy touches only that instance’s runs, and taking down an instance’s trigger touches only the runs that trigger started.

If the run making the call is one of the runs the call would take down, the last argument decides what happens to it:

  • StopSelf::Keep: it keeps going.
  • StopSelf::Include: it is cancelled with the rest, once the take-down is queued; the call never returns.

If a run is replayed after a restart, a copy it terminated is never terminated twice (for why, go and read your project beyond this run).

Cleaning up after instances

If you want to see which instances there are, ctx.instances().list() (or the ListInstances node) gives one entry per instance weft holds anything for: how many values, connections and live tokens it has, its infra copies, and its triggers. It never hands back a value or a secret.

If you want to see an instance’s runs, weft executions --instance user-42 lists them. weft clean takes the same filters (instance, status, the node that started the run, tag), and so does a program’s ctx.runs():

weft clean --project <id> --instance user-42 --yes
let week = Duration::from_secs(7 * 86400);
ctx.runs().instance("user-42").status("completed").older_than(week).clean(RunningPolicy::Wait, StopSelf::Keep).await?;

If a program wants to read its runs rather than delete them, the same filters end in .list(limit) (the newest limit runs, 1 to 200, with total, how many match in all) or .count(). The ListRuns and CountRuns nodes do the same from the graph.

let failed_today = ctx.runs().instance("user-42").status("failed").count().await?;

clean never deletes a run that is still going. With --cancel-running (or RunningPolicy::Cancel) it cancels it, and the run’s rows go with the next clean; without it (RunningPolicy::Wait) the run is left to finish.

weft has no “delete an instance” command. The WipeInstance node removes everything attached to one: its triggers, the copies of the infra nodes you name in its infra input, its values, its connections, its tokens, its files, and its runs. If a wipe stops part way, run it again to finish it.

The instance nodes

If you want a graph to bring an instance up and remove it without writing code, the instances package of the standard catalog has a node for each step:

NodeWhat it does
StartInstanceInfraStarts an instance’s copy of an infra node, and fires once it runs
StopInstanceInfraStops it, keeping its disk
TerminateInstanceInfraDeletes it and its disk
InstanceInfraStatusReads the state of an instance’s copy
ListInstanceInfraLists every copy of an infra node, the shared one and each instance’s
ListInstancesLists every instance weft holds anything for (values, connections, copies, tokens, triggers), with counts and states, and the events waiting on a field not yet filled
CurrentInstanceSays which instance the run is for: fires instance with its id, or none
InstanceCostsLists what an instance’s runs cost, with the total
GetInstanceValuesReads an instance’s values for the @instance_filled fields, keyed node.field
SetInstanceValuesSets or clears an instance’s @instance_filled values, and sets up again any of its triggers that read a changed one
ActivateInstanceTriggersTurns on an instance’s triggers
DeactivateInstanceTriggersTurns them off
MintInstanceTokenMints an instance token to hand to a browser
WipeInstanceRemoves everything attached to an instance

If your backend should create instances, put MintInstanceToken, StartInstanceInfra and ActivateInstanceTriggers behind a gated route, in that order, and reply with the token before the copy comes up so the request does not wait minutes. If one of the instance’s triggers reads a value with no fallback, activating it at creation is refused, so leave ActivateInstanceTriggers for a second call your website makes once the settings are saved. The same route can remove an instance with WipeInstance: weft cannot tell when an instance is no longer needed, so your backend says so.

When something goes wrong

What you seeWhat it means
'bridge' exists once per instance, so this run needs to know which instance it is forThe run reaches something per instance and nobody named one. Add --instance, or start the run through an instance’s trigger, with an instance token, or with the Weft-Instance header on a gated route
instance 'user-42' has not filled 'read.spreadsheet'The run needs a value that instance never got. Fill it in on its settings page, or your program sets it with SetInstanceValues or ctx.values()
instance 'user-42' at 'digest': ...That instance’s value breaks the node’s own rule, which the rest of the message states. Change it on its settings page, or your program does with SetInstanceValues
connect your account at 'google' first: this list is read through itA list on the instance’s settings page is read through its connection at that step, and none is connected yet. Connect it first, at that step
infra not running for: bridge (instance 'user-42')That instance’s copy is down. The message names the command that starts it
these triggers' infra is not running: ...You activated an instance’s trigger before its copy was up. Run the command it names, then activate again
the Weft-Instance header is honoured only on a route gated by a connectionYou sent the header to an open route. Gate the route, or use an instance token
a bare fire runs for no instanceAn instance header reached a webhook’s plain address. Call the route at /connect/...
this token has expired; ask for a new oneThe instance token’s expiry has passed. Mint a new one
this door takes an instance tokenA token that is not an instance token (a plain api token, say) reached an instance’s settings page. Mint one with weft token mint --instance <id> or ctx.tokens().mint_for_instance
an instance connects its own account; the shared key is the program author'sSomebody tried to connect an instance with the shared key. Connect the instance’s own account

Deploying to your cloud

If you want a program to serve real people, you put weft on your own GCP project once, and then send each project there with the commands you already use on your machine, adding --on prod. prod is a name you give your cloud install in the project’s weft.toml, and weft calls it a target.

You do two jobs, from two repositories:

  • The install, once, by a workflow in your own fork of weft on GitHub.
  • Each project, from its own repository: weft activate --on prod sends the program, and a workflow written by weft ci add sends its frontend to Cloud Run.

Install weft on GCP

If you work with Tangle, it can do this whole section for you. It first gets gh and gcloud installed and logged in on your machine (the sign-ups, the billing card and the two logins are the parts only you can do, in your browser), then runs every step below itself.

Once, by hand

Before the install workflow can create anything, it needs permission to act on your GCP project, and a bucket where Terraform (the tool that creates the cloud pieces) keeps its state. You need gcloud logged in as an owner of a GCP project with billing on.

Create a new GCP project just for weft, with nothing else in it. Weft’s own accounts get broad rights inside the project they run in: they create and delete machines, services and service accounts there, and can act as any service account in it. In a project of its own, that reach stops at weft. The programs you deploy never get those rights; each one runs as an account of its own that can only reach what it needs.

The commands below turn on the services weft uses, make the bucket, and create a robot account (a service account) that your fork’s workflows may act as. You store no key in GitHub: GitHub tells Google which repository a workflow runs in, and Google lets only FORK act as the account.

Replace the three values on the first line. FORK is your fork’s owner/repo, with the same capitals as on GitHub, because Google compares it letter for letter.

If you are on the free tier and have no reason to be anywhere in particular, keep us-west1 and put the database (below) in Neon’s aws-us-west-2: both are in Oregon, so the install’s calls to its database stay short. Google’s always-free machine and 5 GB of storage exist only in us-west1, us-central1 and us-east1, and Neon runs on AWS and Azure rather than Google, so that pair is the one place both free tiers sit side by side.

PROJECT=my-project REGION=us-west1 FORK=me/weft
NUMBER=$(gcloud projects describe $PROJECT --format='value(projectNumber)')
# On a new project IAM can take a minute to catch up (with the services just
# enabled, with the account just made), so each IAM step below is tried
# again, quietly, for up to three minutes; a last try shows its own error.
retry() {
  for _ in $(seq 18); do
    "$@" >/dev/null 2>&1 && return 0
    echo "waiting for IAM to catch up, trying again in 10 seconds"
    sleep 10
  done
  "$@"
}

gcloud services enable --project $PROJECT \
  iam.googleapis.com iamcredentials.googleapis.com sts.googleapis.com \
  cloudresourcemanager.googleapis.com serviceusage.googleapis.com

gcloud storage buckets create gs://$PROJECT-weft-state --project $PROJECT \
  --location $REGION --uniform-bucket-level-access

gcloud iam service-accounts create weft-installer --project $PROJECT
retry gcloud projects add-iam-policy-binding $PROJECT --role roles/owner \
  --member serviceAccount:weft-installer@$PROJECT.iam.gserviceaccount.com

pool() {
  gcloud iam workload-identity-pools describe weft-install --project $PROJECT \
    --location global >/dev/null 2>&1 \
  || gcloud iam workload-identity-pools create weft-install --project $PROJECT \
    --location global
}
provider() {
  gcloud iam workload-identity-pools providers describe github --project $PROJECT \
    --location global --workload-identity-pool weft-install >/dev/null 2>&1 \
  || gcloud iam workload-identity-pools providers create-oidc github \
    --project $PROJECT --location global --workload-identity-pool weft-install \
    --issuer-uri https://token.actions.githubusercontent.com \
    --attribute-mapping google.subject=assertion.sub,attribute.repository=assertion.repository \
    --attribute-condition "assertion.repository=='$FORK'"
}
retry pool
retry provider
retry gcloud iam service-accounts add-iam-policy-binding \
  weft-installer@$PROJECT.iam.gserviceaccount.com --project $PROJECT \
  --role roles/iam.workloadIdentityUser \
  --member principalSet://iam.googleapis.com/projects/$NUMBER/locations/global/workloadIdentityPools/weft-install/attribute.repository/$FORK

The robot account owns the project, because Terraform turns on the rest of the Google APIs weft uses and creates the network, weft’s Cloud Run services and the accounts everything else runs as.

Then, in your fork’s settings on GitHub, add these repository variables:

VariableValue
GCP_PROJECT_ID$PROJECT
GCP_REGION$REGION
GCP_ZONEa zone inside it, like us-west1-b
TF_STATE_BUCKET$PROJECT-weft-state, the bucket you just made
GCP_WORKLOAD_IDENTITY_PROVIDERprojects/$NUMBER/locations/global/workloadIdentityPools/weft-install/providers/github (echo $NUMBER prints the number)
GCP_INSTALL_SERVICE_ACCOUNTweft-installer@$PROJECT.iam.gserviceaccount.com

A database

weft keeps everything in a Postgres database you bring, and only needs its address. Any Postgres the internet can reach works. If you want the install to cost next to nothing while nobody uses it, pick one that scales to zero: once weft’s services have scaled to zero they hold no connection to it, so the database can sleep (for what keeps it awake, see what you get). If you work with Tangle, it can create one that scales to zero and set the secrets below for you.

Put its address in a repository secret named WEFT_DATABASE_URL, as a connection URL (postgres://user:password@host/db?sslmode=require).

If that address goes through a pooler that hands out a connection per transaction, also add a direct address of the same database as WEFT_DATABASE_LISTEN_URL. weft waits on changes to its rows with a Postgres LISTEN, which needs one connection that stays open, and such a pooler hands out a different connection for each transaction. If you leave it out and the address cannot listen, weft refuses to start and its log names this secret.

OAuth apps of your own (optional)

If your programs sign in to services through OAuth apps of your own (the apps file), put that file’s contents in a repository secret named WEFT_ACCESS_APPS. Otherwise skip it.

Run the workflow

Open the Actions tab of your fork. GitHub turns workflows off in a new fork, so if the tab asks, enable them first. Then pick install on GCP and run it. It takes weft’s CLI and images from the release when your fork holds the same source (and builds the ones it changed), pushes the images to your project, and creates everything listed under what you get. If you run it right after weft’s main moved and your fork caught up, the release for that source may still be building: the workflow waits for it rather than compiling the same thing, and says so in its log. When it does have to build, it keeps the compiled dependencies for the next run, so after an upgrade only what changed in weft compiles again.

When it finishes, its last step (in the run’s summary, and in its log for gh run view <id> --log) gives you the install’s address, a Cloud Run address like https://weft-role-dispatcher-123456789.us-west1.run.app, and the line that logs you in with the first operator key: the password your CLI uses to act on the install. The key is kept in Secret Manager and never printed in the workflow’s logs; that line reads it on your machine (it needs gcloud) and hands it straight to weft login, below.

The address works at once, over HTTPS, with no extra charge. If you want a name of your own instead, see your own domain, which costs money.

If you want to upgrade weft, merge upstream into your fork and run the workflow again; Terraform changes only what is different from last time. Then rebuild your CLI from that commit, or activate refuses to deploy (when something goes wrong).

If you want weft gone, delete the GCP project you made for it: weft and everything it ever started live in that project and nowhere else, so that is the whole uninstall. Unlink its billing first, so it stops counting against your billing account at once:

gcloud billing projects unlink $PROJECT
gcloud projects delete $PROJECT

Google keeps a deleted project for 30 days, during which gcloud projects undelete $PROJECT brings it back. The database is yours and stays where it is: delete it from its own provider if you no longer want it.

What you get

While nobody is using the install, no trigger keeps a connection open, and no program’s infrastructure is up, none of weft runs, apart from a check every few hours that wakes each part for a moment.

PieceWhat it is
Cloud Runweft’s dispatcher, broker, listener and supervisor, a service each, and your programs’ workers, one service per program. Each scales on its own load, and to zero between calls. A service at zero starts again when it is called: by the part of weft that just wrote work for it, by a Cloud Tasks wake, or by a call from outside (your CLI, a route, a provider’s webhook)
The holdersa Cloud Run worker pool for the triggers that keep a connection open between events (a stream, a socket, an event subscription that dials out). weft runs one holder per 200 such triggers and none when there are none
Your databaseeverything weft keeps, at the address you gave it
Cloud Buildbuilds your programs’ images when you deploy
Pub/Subthe cloud-builds topic, on which Cloud Build announces each build’s end, so the dispatcher hears it at once, and one carrying Compute Engine’s record of an infrastructure machine stopping or failing to the supervisor
Cloud Tasksevery timer, schedule and poll your programs set, and the wakes weft schedules for itself to check on its own pending work
Compute Engineone machine per infrastructure unit your programs start (a database, a GPU model)
A storage bucketweft’s files, reached as weft’s own service account: no key is made for it, so an organization that forbids service-account keys runs weft as is
Artifact Registrytwo image repositories: one for the runtime, workers and infra nodes, which only the install writes, and one for frontends
Secret Managerevery secret weft uses

Most triggers need nothing running between events: a route or a form is called, a timer or a poll is woken by Cloud Tasks, and a service that pushes its events (a provider’s webhook) calls the install. If you want to know when an event subscription takes a push instead of holding a connection, go and read how a trigger picks its road. You pay for a holder while it runs. A running holder also checks in with weft every 10 seconds, so weft’s broker and your database never get to sleep while such a trigger is on. A program’s infrastructure does the same while it is up: the supervisor checks its health every 30 seconds, through the broker, so the database stays awake until you stop it. If a holder crashes, its triggers hear nothing for up to about 40 seconds: its claims run out 30 seconds after it last renewed them, and another holder, or its restarted copy, takes them at its next look. A holder that is stopped normally hands its triggers over at once.

If you want to change one of the install’s defaults (how many triggers a holder takes, a holder’s CPU and memory, how many builds run at once, how many bad tokens a minute one address may present), edit it in deploy/terraform/gcp/variables.tf in your fork and run the workflow again.

weft generates the rest of its secrets itself: the key your saved connections are encrypted with, the key live callers’ tickets are signed with, and the first operator key. The encryption key is made once and kept in the Terraform state, in the $PROJECT-weft-state bucket, and copied into Secret Manager, which is where weft’s services read it. If it is ever lost or replaced, every saved connection stops working and has to be made again, so keep the bucket and never run terraform destroy.

A cloud install belongs to one person. Every project on it shares one private network, so a project’s code can reach another project’s infrastructure there; keep projects you would not trust with each other on installs of their own.

Your own domain

If you want the install, or one of your projects, at a name like weft.example.com, buy the domain from any registrar.

A domain needs a load balancer in front of the install, which holds the domain’s certificate. Google bills it by the hour while it exists: about $18 a month, plus $0.008 per GB that goes through it. weft makes one with your first domain, every later domain shares it, and removing the last one takes it down. For your first domain, pass --accept-cost to say you accept that charge; without it, weft domain add stops and prints the cost. Run this from any project that has the install as a target:

weft domain add weft.example.com --on prod --accept-cost

It prints the DNS record to set at your registrar (type A, the name, and the load balancer’s address) and waits until the name points there. Google then issues the domain’s certificate once it sees the record, and https://weft.example.com works. weft domain list --on prod shows every domain with its record.

A domain can also serve one project instead of the whole install: --for api answers that project’s routes at the root of the domain (https://api.example.com/users/42, as well as https://<the install's address>/connect/local/users/42), sending its calls straight to the project’s own Cloud Run service, and --for frontend --to <address> passes visitors on to the project’s frontend on Cloud Run, at the address weft frontend ls --on prod shows for it.

Deploy a project

In the project’s folder, give your cloud install a name, log in to it, and deploy:

weft target add prod https://weft-role-dispatcher-123456789.us-west1.run.app
gcloud secrets versions access latest --secret <the secret> --project <project> | weft login prod --key-stdin
weft infra start --on prod   # only if the program has infrastructure
weft activate --on prod

If your program has infrastructure (a database, a bridge), start it before the first activate: activate never starts it for you, and if a trigger reads infrastructure that is not running, it refuses with these triggers' infra is not running: db, naming each piece. weft infra start --on prod builds the program and brings its infrastructure up, which on GCP means a machine per piece booting, so give it a few minutes; weft status --on prod shows how far each one got.

weft target add records the install’s name and address in weft.toml. Commit it, and your team gets the same prod:

[targets.prod]
url = "https://weft-role-dispatcher-123456789.us-west1.run.app"

weft login prod takes an operator key (from its hidden prompt, or piped in with --key-stdin), checks it against the install, and keeps it in ~/.config/weft/credentials.toml, which only you can read. If a teammate needs their own key, run weft token mint --operator --on prod and send them what it prints. To see the keys you have handed out, run weft token ls --on prod, and to cancel one right away, weft token revoke <id> --on prod.

Every command takes --on the same way: weft status --on prod, weft executions --on prod, weft logs <execution-id> --on prod. Without it, a command acts on your own machine; nothing makes prod the default.

What activate --on prod does

Your machine compiles the project first, so a mistake shows up on your screen before anything is uploaded. Then it uploads the project’s sources, only the files the install does not have yet, and the install does the rest: it compiles them again itself, builds the images the program needs, and activates.

Deploy a change

Once the program is on, weft activate refuses (these triggers are already on). If you want a change to go live, resync instead: it takes the triggers down and brings them back up on the new version.

weft resync --on prod --mode park

On prod, pick park or hibernate, because the events arriving while the triggers are down can be real people’s: park keeps them and runs them on the new version once the triggers are back, hibernate does the same for a grace window (--grace, in minutes). A caller on a route is not kept: it is answered 503 with a Retry-After, and its client tries again. wipe drops them and cancels the work waiting on the triggers, which is fine on a dev install.

Only the change is uploaded, and only the image it touches is rebuilt. If nothing changed, nothing is built.

Roll back

Each install keeps its own history of what was deployed to it. If you want to go back, find the version in weft tree --on prod, then let weft branch put its files back in your folder and deploy them:

weft branch <version> --on prod
weft resync --on prod --mode park   # or `weft activate --on prod` if it is off

weft branch stops if your folder holds changes no version records yet; --discard throws those away. That version’s images still exist from its first deploy, so nothing is rebuilt.

Connections

Each install keeps its own connections and its own picks of them: which connection a step uses is picked on the install, never written in your files (picked on each install). A step you connected on your machine has no pick on prod until you make one there, with the same walkthrough:

weft connect --on prod

If a run on prod reaches a step with nothing picked there, it is refused before it starts, and the message names the step and weft connect --node <step>; add --on prod. An instance’s own connection (a field written @instance_filled) lives in the install it was made on, so your customers connect through your frontend on prod, the same way they do locally.

Look at prod from the editor

If your project names a target, the graph has a switch in its top right corner: local, then each target. If you want to see what prod is running, click prod. The graph then shows the program as it was last deployed there, along with prod’s runs, status and connections. From then on, Run, Activate, the Executions list and the version tree all go to prod, as if you had typed --on prod on each command.

You cannot edit the program there, because it is a copy of what prod runs. You can change its connections, though: Connect and Disconnect on a step change which connection prod uses, and prod sets up again any trigger that relies on it. If the files in your folder are a different version from prod’s, a banner at the top says so. Activate deploys the files in your folder, exactly like weft activate --on prod, not the copy you see on screen.

If you want to edit again, click local; closing the graph does the same. If a target shows faded, you have not logged in to it yet: log in to it as in deploy a project. And if you want prod’s program as files you can read, weft running-source <folder> --on prod writes them into a new folder and leaves your own files alone.

Deploy the frontend

If you want the project’s frontend (in front/) on the cloud, it runs on Cloud Run, calls weft at the install’s address with a token of its own, and reaches the program’s infrastructure (its database) over the install’s private network. In the project’s folder (the repository must already be on GitHub, and you must have run weft login prod):

weft frontend add front --repo me/shop --on prod
weft ci add --cloud gcp
weft target export prod --github

weft frontend add reads the repository’s id with gh (access is granted to that id, so nobody who takes the name later gets it), makes the frontend a Cloud Run service of its own, public from the start, and lets that repository’s workflows sign in to your GCP project and change that one service: they cannot touch weft’s own services or any other frontend’s. Every frontend deploys as the install’s one deploy account, so a repository you add could deploy to another project’s frontend too; add only repositories you trust with all of them. The name (front here) is yours to pick: lower-case letters, digits and hyphens. weft frontend ls --on prod lists a project’s frontends, and weft frontend rm front --on prod removes one, its service included.

If your frontend runs somewhere else (Vercel, a server of your own), leave out --repo: the install makes nothing, and the command writes the frontend’s token, with the install’s address, to a file only you can read. Put those in the frontend’s environment.

weft ci add writes .github/workflows/deploy.yml, a workflow you run by hand from the Actions tab. It takes the weft CLI the release built from the source your install runs (waiting for it while that release is still building, or building it when the release has none), deploys the program (weft activate --on prod when it is off, weft resync --on prod --mode park when it is on: edit the mode in the file if hibernate or wipe suits the program better), then builds front/ with Docker and deploys it to the repository’s service. If you run weft ci add again, it replaces the file only if you have not edited it; otherwise it stops and leaves your edits alone. If you are starting a new project, weft new <name> --ci gcp writes the workflow for you.

weft target export prod --github uses the GitHub CLI (gh) to set the variables and secrets the workflow reads, so run it with gh logged in. It mints an operator key for the workflow, and gives the frontend the install hosts for this repository a new token, with the name of its service (its first one: weft frontend add makes none for a hosted frontend). Once it has been deployed, its old token keeps working until the workflow’s next run has deployed the new one, and then the workflow retires it. If you would rather paste them yourself, leave out --github and it prints everything. If you use the printed version, copy the secrets straight away: they are never shown again.

The frontend’s server reads three variables, which the workflow sets on Cloud Run:

VariableWhat it isValue on your machine
WEFT_DISPATCHER_URLwhere the server calls weft: the install’s address, over HTTPShttp://127.0.0.1:14111
WEFT_TOKENthe token the server calls with; never send it to a browser (on Cloud Run, the one weft target export made)a token from weft frontend add <name>, written to a file only you can read
WEFT_PUBLIC_URLthe start of any link a browser followshttp://127.0.0.1:14111

If the frontend’s server needs more than those three (the program’s database, a sign-in secret), put them in one file on your machine and hand it over with the export. Keep that file out of git.

  1. weft infra env <node> --on prod --into front/.env.prod --set ... writes a database’s values into it, the same way it does locally.
  2. weft infra list-doors --on prod prints the database’s private address. Only what runs inside the install’s network can reach it, and your Cloud Run frontend is one of them.
  3. weft target export prod --github --front-env front/.env.prod stores the file as the WEFT_FRONT_ENV secret, and the workflow puts it on Cloud Run beside weft’s own variables.

Cloud Run tells the container which port to use in $PORT, so front/Dockerfile has to build a server that listens on it. If your frontend builds differently, edit the workflow’s “build the frontend” step.

Routes on the internet

A Route answers at https://<the install's address>/connect/local/<path>, where <path> is the route’s path. The local in that path is the same on every install, prod included, so a route’s address on prod differs from your machine’s only in the host. The project also answers at its own Cloud Run address (weft status --on prod shows it), where the call reaches your program with nothing of weft’s in between: give that one, or a --for api domain, to a client that cares how fast it is answered. A browser opening a socket on a route that checks its callers is handed an address under the same path on the same host, carrying a ticket, so one address and certificate cover it.

If you want to stop a flood of calls from costing you money, each route has its own limits, set on the node: callsPerMinutePerCaller (60 by default), callsPerMinute for everybody together (none by default), and callsAtOnce (100 by default); set any of them to 0 to turn it off. A call past a limit gets 429 with Retry-After before any run starts. weft status --on prod shows the refusals of the last minute or two under “refused calls”: which route, how often, and by which limit. If the program runs on several copies of its worker, each copy counts calls itself and hears the others’ counts once a second, so a per-minute limit can let through about one second’s worth of extra calls, and callsAtOnce is shared out between the copies that are up.

When something goes wrong

  • 401 on every command: the key is wrong or was revoked. Run weft login prod again. In CI, run weft target export prod --github again.
  • If you want weft’s logs, the services’ logs are in Cloud Logging: gcloud logging read 'resource.type="cloud_run_revision"' --project <project> --freshness 1h. The holders’ logs are under the worker pool weft-holder in the Cloud Run console.
  • HTTPS fails at a domain right after weft domain add: Google has not issued its certificate yet. Check that its record points at the address weft domain list --on prod prints; the certificate follows once Google sees it.
  • weft domain list says the door in front of your domains refuses to follow them: Google refused to change the load balancer, and the message says why. weft tries again on its own, waiting longer each time, up to six hours. Once you have fixed the cause, any weft domain add or weft domain rm tries again at once.
  • weft does not start, and its log says the database session “cannot LISTEN”: WEFT_DATABASE_URL goes through a pooler. Add a direct address of the same database as the WEFT_DATABASE_LISTEN_URL secret (a database) and run the install workflow again.
  • A trigger that keeps a connection open shows “waiting for a holder to take it” on its node: a running holder with room takes it within about 10 seconds; otherwise weft is starting one. If it stays that way, the holders’ logs say why.
  • A run on prod is refused with “has no … connection picked on this install”: the step was connected on your machine, not on prod. Run the weft connect --node <step> it names, with --on prod.
  • An image build fails: the error quotes the builder’s log, with the cargo or apt line that failed. Fix what it names and deploy again; only what changed rebuilds.
  • The frontend deploy fails at its google-github-actions/auth step, or its gcloud run deploy is refused run.services.get: the repository is not the one its frontend was added with (weft frontend ls --on prod shows it, spelled as GitHub spells it, owner/name). Remove the frontend and add it again with the right --repo, then run weft target export again.
  • The install workflow stops in “create the cloud”: Terraform’s output in that step names the resource it could not make and why. If it names a quota (Compute Engine addresses or CPUs, say), raise it in the Google Cloud console and run the workflow again.

How the runtime is built

The runtime has four roles, each with one job.

What it doesWhat it never does
WorkerRuns your compiled programTouch Postgres: it goes through the broker
DispatcherDecides what runs, and answers every request about a project or a runRun your step code
ListenerHolds the timers, the open sockets and the subscriptionsRun your step code, or know which project it serves
SupervisorCreates and watches the machines and containers your program asked forRun your step code, or change a project’s infrastructure while another supervisor is changing it

The dispatcher, listener and supervisor, together with the broker, are parts of one binary, weft-runtime, and on your machine one weft-runtime process runs all of them. On a cloud install each is a Cloud Run service of its own, which scales with its own load and down to zero between calls. Triggers that keep a connection open run separately, on holders: for how they work, see the listener, and for what they cost, see what you get. Workers are always separate: one per program, started by the runtime.

Anything that has to survive a crash goes into Postgres, and every role reads it back from there.

flowchart TD
    UI["CLI / editor / webhooks"] --> D["Dispatcher"]
    D --> L["Listener"]
    D -->|"run this, hand it this event"| W["Worker: your compiled program"]
    C["Live caller"] --> W
    L -->|"an event"| W
    D -.->|"queued work"| S["Supervisor"]
    D --> PG[("Postgres")]
    L --> B["Broker"]
    S --> B
    W --> B
    B --> PG

Where a run lives

Whether a run survives its worker dying is set by its trigger’s durable input, or for a run you start by hand, by weft run --durable or --fast. For what it does, go and read how a run is kept, and for when each kind of run waits for the database, when a run waits for its writes.

Once a run has written anything, it has one row in the database, and the row says where the run is: waiting for a worker, running on the worker it names, parked on a wait with no worker, or ended.

When a trigger starts a run (a caller on a Route or a Socket, or an event such as a timer, a subscription or a webhook), the run starts on the worker that took the call or the event, and its row names that worker from its first write. Every other run starts as a queued row: a run you start with weft run, a paused run picking back up, a run another worker handed back, and the setup runs that weft activate and weft infra start make. The dispatcher hands a queued run to a worker, which claims it (the row names that worker from then on) and carries it on from its record.

If a trigger starts a fast run and its worker dies straight away, the run may never show up in weft executions, because a fast run’s record trails a few milliseconds behind it and may not have reached the database yet.

Where a call goes

If a caller uses the project’s own address, it reaches your program’s worker straight, with nothing of weft’s in between. On your machine the worker running the project’s current program opens the project’s own port itself, so http://127.0.0.1:14200/cards is the worker answering. For how that port is picked, go and read answering on a URL. On a cloud install each project is a Cloud Run service of its own, open to every caller, and that service’s address is the project’s address. A weft domain add --for api domain sends its calls straight to that service. weft activate prints the address, and weft status shows it. For how a browser opens a socket on a route that checks callers, go and read answering on a socket.

The address an install shares between its projects (/connect/local/..., the tunnel’s hostname, the install’s own domain) still works: the dispatcher picks the project and the program by the route, from routes it holds in memory, and passes the call on. It adds headers giving the caller’s address and the Host they sent, and the worker trusts those headers only when the call carries weft’s own key. The dispatcher checks nothing else and writes nothing.

Once a call reaches the worker, the worker does the checking: which route the call is for, whether its trigger is on, the instance it names and the route’s limits on how many calls it takes, and it asks the broker to check the route’s credentials when the route asks for one. The worker holds the project’s routes in memory, and the broker tells it the moment one changes. Unless the route asks for a credential or the call carries an instance token (which names the instance the call is for), nothing reaches the database before the run starts.

If a route’s trigger is parked, hibernating within its grace window, or still being set up, the worker answers 503 with a Retry-After at once, so a client that retries gets through once the trigger is on. When a new version of the program replaces the worker, on your machine the port stays closed while the old worker hands its runs back or ends them (10 seconds at most, then it is killed) and the new one starts. A caller arriving then is refused, as with any server restarting.

If a route still belongs to another version of the program than the one at the project’s address (a new version is taking over), the call gets the same 503 and Retry-After, and lands on the new version once it holds the route.

What happens when an event arrives

When a timer fires or a subscription delivers something, the listener holding it hands the event straight to the worker of its project, through the same door callers use: the worker checks whether the trigger takes work now and the trigger’s limits, and works out what the run reads. If the trigger is parked, it is past its limits, or what the run reads is not ready (its infrastructure is down, its instance has not given a value it needs), the event waits in the trigger’s queue, and the dispatcher hands it over again once it can run. Otherwise the worker starts the run as a fast or durable run, whichever its trigger asks for, and writes what happens into the journal as it goes.

A webhook or a provider’s push arrives at the install’s own address: the dispatcher has the listener read it, then hands it to the worker the same way. An answer to a run that waits (a form, a person’s reply) goes through the dispatcher, which resumes that run. When the listener cannot reach the worker (the project has no address now, the call fails or goes unanswered), the event waits in the trigger’s queue the same way.

Whoever handed the event over keeps its call to the worker open until the run ends, so the platform counts the run as work: Cloud Run keeps giving the worker CPU, and a local install does not stop it as idle.

The worker

A worker is one compiled program, serving many runs at once. It runs from an image named after a hash of its contents, so two projects that compile to the same thing share an image.

On your machine a worker is a Docker container, one per project and build of its program; changing the project’s worker settings (weft workers set) starts a fresh one for the calls that follow. The one at the project’s address runs for as long as any of the project’s triggers takes work. Any other starts when a run needs it and stops after thirty seconds with nothing to do, unless the project keeps copies warm with weft workers set --min-instances. To change the thirty seconds, set platform.workerIdleStopSeconds in the install’s config.json (~/.local/share/weft/config.json) and restart the daemon.

On a cloud install a worker is a Cloud Run service. Cloud Run starts more copies of it when calls pile up and removes idle ones when it decides to, down to none, so a project nobody calls costs nothing. If you keep copies warm with --min-instances, those are billed even while idle. If you want to change how many calls Cloud Run sends one copy at once (--concurrency), the most copies Cloud Run may start (--max-instances), or a copy’s CPU and memory (--cpu, --memory), weft workers set changes them for one project. On your machine none of these four does anything: there is no Cloud Run sending calls or starting copies, and weft puts no CPU or memory limit on a worker.

On a cloud install, if a run keeps working after its caller has gone (a Route with outlivesCaller that replies and carries on), the steps it was running when the caller left finish on that copy, and the rest of the run moves under a call weft holds open (for how, go and read how a run is kept). Cloud Run throttles the CPU the moment the answer goes out, so if those last steps are heavy, turn on weft workers set --cpu-always-allocated true. With it, the copy is billed for as long as it is up.

A thing a worker’s runs share (ctx.shared, such as a pool of database connections) lives in the worker too: it goes once no run has used it for five minutes (weft workers set --shared-idle-seconds changes that), and at the latest when the worker stops. For how a node asks for one, go and read the ctx.

A worker keeps, between runs, what its runs read of the program’s infrastructure and connections: where each piece answers, the connections the broker handed it, and the ones an infrastructure node published. That rarely changes between runs, so after the first run a call no longer asks the broker where things answer or for its connections, and starts its run straight away. Whenever one of those changes (a piece restarts somewhere else, a connection is replaced), the broker tells the worker, which drops what it kept and asks again. Two exceptions: a credential weft hands to a single run is never kept, and a credential that expires is kept only until it is due a refresh.

If a run may need more than an hour on a cloud install, go and read how a run is kept.

When a worker is full

A worker takes a set number of calls and events at once. Unless you set it, that is one for every MiB of the worker’s memory (on your machine, the machine’s), and never fewer than 64. If you want another number for a project, weft workers set --max-runs-at-once sets it. A run handed to the worker from the queue (a weft run, a resume) is not counted.

If a call arrives while the worker is full, it waits up to 30 seconds (--max-queue-wait-seconds) for a run to end, before any of its body is read. At most as many calls can wait as the worker takes at once. A call that waited the whole time, or found no room to wait, gets a 503 saying This worker is busy right now; try again in a moment., with a Retry-After. An event waits the same way, and one that waited too long goes in its trigger’s queue, where it waits until a worker has room for it.

If the database falls behind, runs take longer to end (for why, go and read when a run waits for its writes), the calls queued behind them wait longer, and one that waits past --max-queue-wait-seconds gets the same 503.

The same 503 comes back while memory is more than 90% full, because one run too many would get the whole worker killed, and every run on it with it. On a cloud install that is the copy’s own memory; on your machine it is the machine’s, whatever is using it. The runs already on the worker carry on. If you see it often on a cloud install, lower --max-runs-at-once. Cloud Run never sends one copy more than --concurrency calls at once (80 unless you change it), so a lower --max-runs-at-once only helps if it is below that. You can also lower --concurrency itself, or give each copy more memory with weft workers set --memory. On your machine, free some memory or lower --max-runs-at-once.

The dispatcher

It never sees your nodes/ directory. Your CLI reads your local catalog and compiles the program before submitting it, so the dispatcher only ever handles a compiled definition.

The next request may land on another copy of the dispatcher, so it keeps nothing in memory that another copy would need.

What it does keep is a copy of the rows a call reads every time and that rarely change: a tenant’s routes, the install’s domains, a project’s worker settings, which of its infrastructure is up, the connections the install picked for it and what each instance provides. Every write to those rows makes Postgres tell every copy of the dispatcher, which drops what it held and reads it again on the next call, and anything it is about to refuse (no such route, infrastructure not running) it checks against the rows first. While its connection that hears those announcements is down, it keeps nothing and reads every time.

The dispatcher’s background work (handing runs to workers, answering what a program asks of weft, registering triggers) waits on rows in the database, and Postgres announces each one as it is written. A dispatcher that is up hears that and starts at once, on your machine and on a cloud install alike. On a cloud install, where the dispatcher can be at zero, whichever part of weft wrote the work also calls it to start one.

If you are watching a run in the editor, or waiting on one to end, you hear about it roughly 10 milliseconds after its rows are written. The broker gathers which runs got rows and which ended, and tells the dispatcher in one statement once the write has committed. It announces after the write rather than inside it, because Postgres makes every write that announces something commit one at a time, so announcing from inside each write would line up every write in the database behind the others.

Some endings leave the dispatcher work (finishing a trigger’s setup, clearing a run’s pending waits). If it crashes or loses its Postgres connection it can miss such an ending, but it finds the work anyway: it looks when it starts, when the connection comes back, and every 30 seconds while it is up. On a cloud install where it sat at zero, it also looks within six hours at the latest.

The listener

One listener serves every project on the install. Each kind of trigger says what it needs between two events, and the listener does exactly that:

  • A form or a webhook needs nothing: it is called from outside, and the listener only turns the call into a message.
  • A schedule, a delay or a poll needs a timer. It asks for its next wake, and the install’s alarm delivers it (a table in Postgres on your machine, Cloud Tasks on a cloud install), even across a restart of the runtime.
  • A stream or a socket needs an open connection, so it needs a process that stays up between events. weft calls that process a holder, and it runs the listener’s code. On your machine that is the runtime’s one process. On a cloud install the holders run in a pool, as many as those connections need and none when there are none. Each holder claims the triggers it holds, so no two holders hold the same one, and it renews those claims every 10 seconds. If a holder crashes, its claims run out after 30 seconds and another holder takes its triggers. A holder that is shut down gives them up at once.
  • An event subscription needs a holder only when it dials out to the service. On a cloud install, when the service can push the subscribed events for one account, weft subscribes with the service instead and renews that on a timer, so nothing stays open for it. A subscription to every account of your app always dials out.

If you want a new kind of trigger, you only write listener code: the listener is the only role that tells kinds apart.

The supervisor

It applies your infrastructure and watches whether what it created is healthy. Each piece of infrastructure your program asks for (a database, a model server) is a unit: on your machine a set of Docker containers, on a cloud install a Compute Engine machine of its own, with its disks and GPUs.

Two supervisors changing the same project at once would overwrite each other, so before it changes anything a supervisor takes an exclusive lease on the project. It keeps renewing that lease while the work runs, and if it expires another supervisor picks the project up.

Inside the one supervisor that holds a project, the work on different copies runs side by side: three instances started together come up together. Work on the same copy runs in the order it was asked for, so a stop asked after a start waits for that start.

A supervisor can still die between changing something and recording that it did. The next one works out what to do from what is actually running, rather than trusting the record.

On your machine the supervisor looks at the health of what it runs every thirty seconds. On a cloud install nothing looks while everything is fine, so infrastructure that runs fine costs no look at all and lets the rest of the install sleep: the agent on each machine watches its unit right there, for free, and asks for a look when how the unit stands changes (a container stopped, a check started failing or passing again), and Compute Engine’s own record of a machine stopping or failing asks too. Once something is not fine, the supervisor keeps looking until it is settled, which is what its flaky and recovery windows need.

The broker, and who may talk to what

Only the dispatcher and the broker hold a database connection; the listener, the supervisor and every worker go through the broker.

Each of those processes keeps one WebSocket open to the broker, and every call it makes rides that one connection, numbered and answered in whatever order they finish. A call the broker holds until something happens (new journal rows) costs nothing while it waits, so a worker takes one seat at the broker however many calls its runs hold open, and a call whose caller stops waiting is dropped at the broker too. The broker also pushes down the same connection the changes a worker keeps a copy of (see the worker), and the cancel of a run the worker drives. A connection with nothing to do for a minute closes, and opens again on the next call, so an idle process holds nothing open at the broker, except a worker. A worker driving runs keeps its connection open, to hear their cancels, and an idle one reports to the broker every second (that it is alive, and how many runs it drives), so its connection opens again within a second of closing. When the connection breaks, it comes back by itself: a call not yet sent goes out on the new one if it is back within the call’s wait. Past that, or when a sent call got no answer, two kinds of call are made again because doing so is safe: a batch of records (a batch that arrives twice is stored only once), and a read of a run’s history. A call the broker says it could not even start (every one of its database connections busy) is sent again too, since nothing of it ran. Any other call fails the way a request whose connection reset does, since it may have landed. Uploads and downloads of files stay ordinary requests, so a big file never holds up the calls behind it.

The broker checks every request. The caller proves who it is with a token its platform gave it: on your machine, one the runtime signed when it started the container; on a cloud install, one Google signed for the account the caller runs as, weft’s own or the program’s. The broker works out from that token whose install the caller belongs to and which role it plays, and it checks that the caller may touch what it asked for. Your program’s worker runs untrusted node code, so it never gets a database connection.

The broker also handles storage and connection work, including OAuth exchanges and subscription setup. Your worker calls providers itself, so a slow provider never holds up the broker for everyone else.

On your machine, the API the CLI talks to has no password: anything that can reach it can do what a project owner can. It only answers programs on your own machine (127.0.0.1). For the rest of the boundaries, go and read the security policy.

What happens when something dies

A worker that dies takes its fast runs with it. The exception is a run whose every branch is waiting on a timer or a form: its whole record is written before it pauses, so a new worker picks it up when the wait ends. That exception does not hold while a caller is still on the line and the route leaves outlivesCaller off, or while a bus between its nodes is open: such a run holds its worker instead of pausing. Nobody can tell which of a fast run’s steps ran after the last row that reached the database, and running it again could repeat them, so a fast run whose worker died ends cancelled and is not run again, with this reason: the worker running this run went away; a fast run lives in its worker's memory, so it is not run again (make its trigger durable for a run that survives its worker).

A lost fast run is not marked cancelled the moment its worker dies: the dispatcher notices and ends it. Every worker reports that it is alive through the broker once a second, and counts as gone 15 seconds after its last report. The dispatcher ends a run once its worker has been gone for another 15 seconds, and it checks every 15 seconds, so a fast run ends 30 to 45 seconds after its worker last reported. Its journal holds what reached the database before the worker died.

In that same check, a durable run whose worker died is queued again, and the dispatcher hands it to another worker at once, which carries it on from its record. A step that had started but not finished is failed on the new worker rather than run again, apart from some steps of pure nodes. For which ones, and what to do about a failed step, go and read surviving a restart.

On a cloud install, when Cloud Run replaces a worker or scales one away, it sends the worker SIGTERM, the signal that asks a process to stop on its own, and kills it 10 seconds later. Every run on the worker then leaves it if it can:

  • A run that can pause starts no new step. Once the steps it is running have ended, its record is written whole and it is handed back, so another worker carries it on straight away. A step still running 5 seconds after the SIGTERM is stopped where it is, and the next worker fails that step, the same as if the worker had died. If its caller is still on the line (a route with outlivesCaller on), the run carries on with nobody there, and the caller hears the copy of the program serving this request is stopping, so the run carries on in another one without this caller.
  • Three kinds of run cannot pause: an unrecorded run, a run tied to the caller still on the line (a route without outlivesCaller), and a run with a bus open, since a bus lives in its worker’s memory alone. Each keeps running, starting new steps as usual, until the platform stops the process. If it has not ended by then, that is the same as its worker dying: a fast run ends cancelled, and a durable one is carried on by another worker. An unrecorded run that nothing had written down yet leaves no trace.

At the same moment the worker stops accepting connections. A call that was already in, but whose run had not started yet, gets a 503 saying this copy of the program is stopping; try again in a moment, so the client can retry and reach another copy.

If you restart the daemon on your own machine, the new daemon sends the same SIGTERM to every worker the old one left running and removes each once it has exited, so their runs go the same way. When the new daemon sets up a project’s own address again, it kills that project’s old workers 10 seconds after sending them SIGTERM, so the new worker can open its port. If you would rather not wait for an old worker to exit, docker rm --force <container> stops it now, and the runs still on it end as if it had died; the daemon’s log (~/.local/share/weft/runtime.log) names each such container.

If a worker cannot get a batch of records written (the broker errors or never answers), the runs in that batch end failed (for why, go and read why a failed write stops the run).

If a worker freezes or loses its connection to the broker, it stops reporting, and its runs go the way a dead worker’s do: 30 to 45 seconds later a durable run moves to another worker and a fast run ends. The run’s row carries a number that goes up every time a worker takes the run or loses it, and the broker refuses any write made under another number. So from then on the frozen worker’s writes to that run are refused, and it drops the run.

Running it somewhere new

If you want weft on another platform, write a crate that implements the traits below, from crates/weft-platform-traits: everything that depends on where weft runs sits behind one of them.

InterfaceWhat it answers
RunnerHow a program’s workers are started and reached
InfraHostWhere infrastructure units run, and how they are watched
ImageBuilderHow staged source becomes a runnable image
AlarmHow a wake set for later is delivered
CallerIdentityWho is calling an internal endpoint
IdentityTokensHow a role proves who it is to another
FrontendHostingWhere a project’s frontend runs, when the install hosts it
DomainHostingWhat stands in front of the install’s own domains and holds their certificates
HolderPoolHow many holders run, for the triggers that keep a connection open

Files go through one more trait, ObjectStore. If your platform’s store speaks S3 and you can hand it a key pair, you implement nothing: the S3 client (object_store_for) talks to it, the way it talks to SeaweedFS on your machine. GCP has its own (GcsObjectStore), because it reaches Cloud Storage as the runtime’s own service account and has Google sign the links, so no key exists for an organization’s policy to forbid.

Two platforms exist: weft-platform-local (Docker on your machine) and weft-platform-gcp (Cloud Run, Compute Engine, Cloud Build, Cloud Tasks). The install’s config names one, and crates/weft-runtime/src/platform.rs is the one place that reads which.

Things people say to me

“Why a new language? Just make it a library”

For the actual work, weft is a Rust framework. You write a step’s logic in Rust with the APIs weft gives you, and I am not going to invent a new syntax for parsing a response or adding up a total.

The language has one job: saying how the pieces fit together. For that job the syntax is the whole point.

Think about where the architecture of a normal codebase is written down. Nowhere. It is something you reconstruct by reading implementations and working upwards, and that is fine while the whole thing fits in your head.

It stops being fine for a coding assistant, which is working in a window. It goes down into one detail, does a good job on the piece in front of it, and has no idea what shape it was supposed to plug into. So it writes a second version of something that already exists, or a path around the infrastructure it should have used. And if you do not guide it, it writes with no structure at the top at all, because nothing in the language asks for one.

In weft the structure is the source. Scoping a part of the program nests it, so the nesting is free, and a group declares its inputs and outputs before you open it. You read from the top down: collapse everything, see the whole shape, open the one part you are working on. The compiler holds those boundaries, so a wire cannot quietly reach into another group’s insides. It has to appear in the interface, where somebody can see it.

That is what you hand an assistant: the outer structure from the contracts, then one group, one level deeper, with edges around the job. See groups and the commandments of plumbing.

“Won’t better models make this unnecessary?”

A model getting better at untangling a codebase is not a reason to keep handing it a tangled one.

Even if tomorrow’s model held your whole project in its head and never missed a connection, you would still be paying it to read all of it. If a tenth of the context gives it everything the job needs, why buy the other nine tenths? Being smarter does not make those tokens free, or faster to produce.

And you do not have to hand the project to one assistant at all. Agree the contracts, let one arrange the groups, have others work inside them at the same time, and a group can divide again. Weft is built for that kind of parallel work.

“Visual programming always turns into spaghetti”

It absolutely can. Two hundred boxes on a canvas do not become understandable because you can zoom out far enough to see them all.

The difference is that the nesting is not an editor feature, it is in the language. A group is a real boundary with declared inputs and outputs, so folding it away actually removes something from what you have to think about.

You still have to pick useful boundaries, and no editor rescues a design where everything needs to know about everything else. What the compiler does is tell you when a level is getting crowded: past fifteen items on one level it says so, by name.

The graph also has a text form. The .weft file you review in git is the source, and the editor edits that file rather than a separate model of it.

“The compiler checks the wiring, not whether the program is right”

Correct. A perfectly well typed program can send a beautifully formatted wrong answer to the wrong person.

What the checks catch today is incompatible port types, missing required inputs and cycles in the wiring. That is feedback about how the pieces got connected, which is exactly the kind of mistake an assistant makes and can fix without you. It says nothing about whether a step does the right work. That is still your judgement, and the step’s own tests.

Where I want to take it is proving things about how the program behaves, at compilation time: letting a node carry labels about what it is and what it does, then having the compiler check claims across the whole graph, so that an autonomous step cannot reach a high-stakes action without a strong enough verification in front of it. The full argument, and why I think it matters, is in how I think about AI safety.


If something here looks wrong, or you have other arguments, come and tell me on Discord.

Sequential Diffusion Programming

If you already know exactly what you want, ask your assistant for the whole thing and see how far it gets. Sometimes that is the end of it.

This page is for when it goes wrong in one particular way: the program looks convincing, and it keeps getting real cases wrong.

The reflex then is to write a longer, more careful description of what you meant. That rarely helps. Give it one real case instead.

We call building this way Sequential Diffusion Programming. The program gets sharper on every pass, and every pass has an actual input going in and an answer you can judge coming out.

Start with one real input

Say you are turning incoming emails into support tickets. Take an email you actually received. Ask Tangle to pull the customer’s problem out of it, run just that piece, and click Inspect execution on the step that produced the answer.

Now read it properly, before you build anything else. Did it find the problem, or did it summarise the signature at the bottom? If there was an order number in there, is it still there?

Whatever went wrong, hand that back:

This email is asking for a replacement, but the extracted problem says the customer wants a refund. Improve the prompt for this step.

Now you have a real extracted request to test the next step against, rather than something you made up. Keep going until you can follow that first email all the way to the answer you wanted.

You can do the same to a finished draft somebody handed you. Start at its output, find what is wrong, and walk backwards to the step that introduced it.

Keep the cases that taught you something

Try a second email that asks for something different. When it breaks something, fix it, then run the first one again. Otherwise the assistant can make today’s example pass by quietly breaking yesterday’s.

Find the mistake without reading everything

If the program has groups, start with them shut. Look at the output of the group that produced the bad answer, then open it and look at the boxes inside. Keep going until you reach the step where a good input became a bad output.

The verbs a pass uses

If you want toRun
Re-run without redoing what did not changeweft run --seed. Changed code and inputs invalidate the affected work and everything downstream of it
Exercise one step with values you supplyweft run --from node='{"port":value}'
Run up to a step, including itweft run --target node
Run up to a step, excluding itweft run --before node
Run one group on its ownweft run --group group='{"port":value}'
Try a trigger without switching the listening onweft bake, then weft run --fire trigger='<json>'

When a run comes out right, weft freeze <name> keeps its starting parameters and its accepted outputs. After a change, weft run <name> runs the current code with those parameters, and weft diff <execution-id> example:<name> shows you what moved, for you or Tangle to judge. Freezing the new run replaces the accepted example.

For the full rules and every refusal, go and read versions, seeds and frozen examples. What is landing next is on the roadmap.

The commandments of plumbing

A node does its own job and nothing else. Everything underneath it, the credentials, the listening, the storage, the record of what happened, belongs to weft.

Here is where that line sits today, and what it means in practice.

The test underneath all of them: if every node that needs a mechanism would need the same answer, the mechanism belongs in weft. If two good nodes would do it completely differently, it belongs in your program.


I. Thou shalt not handle credentials.

Signing in, signing requests, refreshing, revoking, deciding whose account to use. You get handed a client that already works.

II. Thou shalt not listen for anything.

A trigger says what should wake it up and the runtime does the listening: holding the socket open, taking the push, polling on a timer, checking the event is real, renewing the subscription.

III. Thou shalt not build the channel.

Two nodes alive at the same time talk over a channel weft opens and holds for them. Sockets, reconnects, acks 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 somebody can read it. Not buried in one node’s body where nobody can.

V. Thou shalt not save your own state.

No file on the side, no table of your own, no “I’ll just keep this in memory between runs” for anything a run has to remember. The journal is the run’s memory: a durable run whose worker dies is rebuilt from it by another worker, and ctx.run is how a step that waited reuses its recorded results when it picks back up. A connection pool or a process the runs only share for speed is different: ctx.shared holds one for them, and losing it loses nothing.

VI. Thou shalt not carry files around.

You say what a file is for and how long it should stick around, and you get all of that control. Fetching it, storing it, giving it a public address, expiring it, turning it into whatever shape a provider wants: that happens underneath you.

VII. Thou shalt not keep the books.

A call on a metered connection gets its cost recorded as it happens, as far as the provider’s evidence allows. 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.

No checking that an input is really a string, no unwrapping something you were already promised, no defensive parse at the top of your body. Every wire was checked before anything ran, so the value is the thing it says it is. A wrong one fails before it reaches your code.

X. Thou shalt not write down what happened.

No log file, no print so you can work out later what went on, no audit trail of your own. Every event of a run is journaled (unless its trigger turned recorded off), and that journal is what the runtime reads to pick up a durable run or one that waited, so a record you keep on the side is one the runtime cannot restore from anyway.

XI. Thou shalt not report to the human.

Every value on every wire is already 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 hand-deliver information to whoever is watching.


These are also aspirational. A commandment can be right and weft can still not cover your case yet, or be missing an option you need. If you think the line should move, come and tell us about that one on Discord.

What is left is yours

Your own logic: 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 back there goes wrong, it fails loudly.

Moving the line

The line sits where it does because of the nodes people have written so far.

If you are about to write something you think is plumbing and belongs in weft, come and say so on Discord or in an issue. Bring the node you are building and the code you would otherwise have to stuff in its body. Or build the general version yourself and send a PR, which is usually faster and always welcome; ask on Discord first so you do not waste your time.

Same if a commandment holds but what weft gives you falls short: socket handling that does not fit your protocol, a display that cannot show your kind of result, a scope that does not last long enough. Tell us what you hit.

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 reaches inside without going through it. A group does not exist at run time, so nesting is free: it costs nothing when the program runs, and nothing when you read it.

The plumbing belongs to weft

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, go and 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, and 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

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. For the full rule, go and read the three fronts.

Fail loudly

When something goes 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 person running it 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.

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, with the lever there for whoever wants to change it.

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 stays possible and 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. Where something can only be caught while it runs, the runtime stops it and the message points at the shape that works.

How I think about AI safety

There’s something fundamental about LLMs that most people get wrong: the thing you talk to isn’t the model.

The model predicts probabilities for the next token. A sampling algorithm picks which one actually happens. That token goes back into the context, and the model predicts again. Nobody wrote the assistant down anywhere. The model offers the possible continuations, the sampler picks one, and after enough picks you’re talking to a character.

So if you want a different assistant, you edit some text, start again, and what emerges is different. The character lives mostly in the context, and you can open the context and read it.

That still works, but it’s getting harder, because more and more of the assumptions about what’s being predicted get pulled out of the context and into the weights. Getting a model to actually be the character you handed it takes real effort now, and more of it every year. Those models are still fundamentally simulators though, even if their internal dynamics have changed. For what I think about training, go read Three properties for alignment.

What I’m worried about

I worry about an AI that stays self-coherent over long periods of time. That’s the future I’m trying to make less probable.

There are two ways I see us getting there.

The first is the one most labs are probably aiming at: a single model with either an infinite context or a very compressed memory, where everything that defines its behaviour has moved inside the weights, or the memory, and is no longer in the context. Models are drifting that way. I’d argue that as long as next-token prediction of arbitrary text is still the base and a major chunk of the training, it stays possible to make another persona emerge by tweaking the context hard enough, and we wouldn’t be in this scenario.

The second is a mind emerging at a meta level, through high-bandwidth coordination and high context adherence. Labs would train AIs to be extremely good at coordinating with each other, and at storing and retrieving information through a well organised shared memory they can edit. With the current paradigm, I think this path could arrive earlier than the first one.

Yes, you could use weft for the second one

weft is powerful enough to build that level of coordination. But:

  1. You’d still need AIs trained to act as a singular mind through context alone. weft doesn’t get you there, and I’d argue that’s the hardest part.
  2. weft doesn’t make it much easier anyway. You still have to shape the memory layer properly, and weft helps with that no more than any other language.

What weft actually changes

Every step you take out of the model and write into the graph makes the system more interpretable. Everything a run did is in the journal, and you read it against a graph you already have in front of you, so what would have been a trajectory somebody has to reconstruct is values on wires you drew. The model inside any one node is as opaque as it ever was. What changes is how much of the system is made of model.

Push that all the way and you get something like a CoEm, where the capability comes from a structure you wrote rather than from one unbounded thing. Most programs will sit short of that and I think that is fine. A step can stay agentic wherever nothing irreversible happens off the back of it alone: an implementation pass over a plan you already settled, a literature review, …

The graph decides what runs next, not the model. In an agent the model decides, so anything you want to be true about a run is something you are hoping it keeps doing. Here it is written down, and the compiler checked it before anything started.

A node also only gets what you wired to it, so anything you put around a step sits outside it: a filter on its answer, a second model that checks it, a fact-checker that starts from a clean context, a person, …

The step can’t see any of that and can’t reach it. It doesn’t know whether it is being checked, so it spends no tokens on that, and the check runs because the graph says so rather than because the model chose to.

The checker also starts clean. It isn’t carrying the context that produced the answer, which is usually how a hallucination or a jailbreak gets past a checker that shares it.

Make the safe path the cheaper one, and make it arrive earlier

If you pull the coordination out of the AI’s job and write it down, you get something explicit, controllable, interpretable and cheaper to run, with the same economic benefit as the autonomous version.

My bet is that it’s what makes the scary future less likely. The autonomous version costs more and gets harder to justify building, without anyone having to ban it. And policy people get something they can point to, which is a much easier job than arguing for a full stop.

It’s also why weft can’t be a tool for safety people only. A tool nobody else picks up doesn’t change what gets built, so it has to be worth using simply because it’s a good way to build things.

Where I want to take the compiler

Today the compiler checks how the pieces connect: types, required inputs, graph shape. What I want next is for it to prove things about how a program behaves at compilation time, and I think that’s reachable through richer metadata.

The idea is to let a node carry labels about what it is and what it does. A node that takes an action in the world says so, and says how big that action is. A node that’s an autonomous agent says so. A node that verifies something says what strength of verification it gives you.

Then the compiler can check claims across the whole graph: every path from an autonomous step to a high-stakes action goes through a verification of at least this strength, or a critical action has three separate human approvals in front of it. If the program doesn’t hold up, it doesn’t build.

None of that works if the labels are decoration. So the other half is vetted sets of nodes, checked and signed off by whoever is doing the regulating, with safety-critical programs required to build out of those. Then the labels mean something, and so does everything the compiler proves on top of them.

That’s the piece that turns all of the above from a way of working into a property of the program. It doesn’t exist yet but it’s high in the roadmap.

None of it is necessary for the rest of the safety vision to hold, though. If the language only makes the two dangerous shapes less likely on its own, I’d already call it a win. Where it does become useful is if anyone wants to regulate AI properly, because I think regulation as compilation properties is one of the better shapes AI regulation could take.

Glossary

Access. The port type for permission to call a service. One access node holds the connection and emits an Access; every node that calls that service takes one as an input. Nothing secret is in it.

Access node. The node that owns the connection for one service. Its body is one line; everything it does is declared in its service recipe.

Activation. Turning a trigger on. It runs the trigger’s setup, which registers subscriptions with providers and dials sockets, then leaves the listener running. Each trigger is turned on separately, and a trigger that exists once per instance is turned on separately for each instance. The command is weft activate.

Asset. A file pulled into your project at build time with @asset. Never written back to.

Bake. A trigger’s settings, prepared and saved without anything listening. The command is weft bake, and it is what you want while building.

Baked output. An output of an infrastructure node that weft makes when the infrastructure is applied and then saves. A run that reads nothing else from the node uses the saved value instead of running it. weft infra rebake <node> makes it again. Unrelated to weft bake.

Broker. The scoped front door to the database that every tenant-side process uses. Workers, listeners and supervisors ask it; only it and the dispatcher touch Postgres.

Bus. A live channel between nodes that are running at the same time. Any number of participants, each with its own position. It lives exactly as long as its worker.

Closure. A pulse carrying no value, meaning nothing will ever arrive here for this run. It is how a step says no rather than saying nothing. Not null, which is data.

Connection. One account somebody connected to a service. Its credential lives in weft’s store, sealed, and never in your program.

Dispatcher. The control plane. It routes events, decides what runs where, and answers every request about a project or a run.

Door. How a connection is obtained. shared uses a credential weft holds; own means you bring your own.

Drift. Your source having moved ahead of what is running. weft status names which kind and which verb fixes it.

Durable run. A run whose trigger has durable on: each step is written down before the next starts, so if its worker dies another one picks it up.

Example. A run’s starting parameters, saved as examples/<name>.json. A frozen example also holds the outputs you accept as right.

Fast run. A run whose trigger has durable off, the default: it goes as fast as the program and its record follows behind it. If its worker dies, it ends cancelled and is not run again.

Firing. One go at one step, identified by its run, its step and its frames.

Frames. The stack of loop iteration numbers a firing sits inside. weft only combines inputs whose run and frames match, which is what stops iteration three eating iteration four’s answer.

Gather port. A loop output that collects one value per iteration. It has to be List[T | Null], because a failed iteration leaves an empty slot.

Generator. A stream: one producer, one consumer, ordered, typed, and it ends. A closure on one is its end rather than a skip.

Group. Several steps under one name, with declared inputs and outputs. It does not exist when the program runs.

head. The version your next checkpoint sits under, and the run a --seed inherits from.

Infra node. A node that needs a container of its own. Nothing starts it for you.

Instance. One separate running copy of part of a program, under an id the program picks. It can stand for one person, one session, or one of several things a person owns; which person owns which instances is kept in the program’s own database. A node marked @per_instance gets one copy per instance, and every run is for one instance or for none. See programs with instances.

Instance token. A signal token that acts inside one instance of one program and can do nothing else: it starts that instance’s runs, answers its waits, shows its displays and connects its accounts. It always expires.

Journal. The append-only record of a run, written in rows that each hold one or more of its events. It is what the graph shows you, and what a worker reads to pick up a durable run or one that was waiting.

Keep. Marking a file to survive the sweep that clears a run’s storage. Additive, and there is no un-keep.

Listener. The tier that holds the timers, the sockets and the subscriptions. The only tier that tells one kind of event source from another.

Meter. The code that works out what one provider’s call really cost. A node never states a cost.

Node. One step of a program. On disk, a folder with a metadata.json and a mod.rs.

Pulse. One emission travelling to one input, carrying a value, an execution id and a frame stack. The only thing that moves in a running program.

Recipe. The service block in an access node’s metadata: how a credential is obtained, how a request is signed, what the permissions are, how events arrive.

Registered app. One OAuth application this installation signs people in with, living in the operator’s apps file. Its secret never leaves the store.

Resync. Deactivate and re-activate in one go, against your current program. What you run after editing anything a live trigger reads.

Root. A step no wire feeds. A manual run starts every root at the top level, plus every trigger.

Scope (run). Which part of the graph a run covers, set by --from, --target, --before, --group or --fire.

Scope (storage). Which of execution, project, shared, asset or instance a file belongs to. It decides where new files go and how long they live.

Seed. The run a --seed run inherits from. By default head’s run.

Sequential Diffusion Programming. Building a program stage by stage against a real case, rather than describing the whole thing and hoping.

Signal. Something that wakes a step: a timer, a form, an endpoint, a subscription, a held socket. Registered by a trigger, or awaited mid-flow.

Slug. The stable name on a compiler finding, like type-mismatch. The slug names the rule; the message names the fix.

Stuck. A run where steps are holding values that will never add up to enough to fire them, and nothing is parked on a signal. Nobody can answer, so weft ends it and names every step involved.

Supervisor. The tier that creates and watches your infrastructure’s containers. One holds an exclusive lease per project.

Tag. A label a run puts on itself, which another run can use to stop it.

Tangle. The weft specialist weft new --assistant copies into your project.

Trigger. A node that starts a run from outside. Two bodies: setup, which runs once at activation, and run, which fires on each event.

Unit. One set of containers inside an infrastructure spec, running together on one machine. Most nodes have exactly one.

Version. A snapshot of your project’s files, recorded on every run and by weft checkpoint.

Waiting for input. A run parked on a person or a service. The worker shuts down and costs nothing, and a fresh one picks the run up when the answer lands.

Worker. A container (on your machine) or Cloud Run service (on a cloud install) running your compiled program. For how it starts and stops, go and read the worker.

Where this is going

Held suspensions. Today the durable model kills a worker whenever every lane parks on a wait, and a fresh worker folds the journal to resume. That is what makes parking thousands of cheap flows free. It fails a node that holds in-process state too expensive to rebuild: a browser session with thousands of cookies, a loaded local model. The plan is an opt-in primitive, ctx.hold_signal, where the await happens in place and the worker stays alive. Die-and-resume stays the default, and the language makes the cost visible, because holding keeps a worker up for the whole wait.

Suspendable live channels. A bus is pinned to one worker, both ends have to stay alive, and await_signal is forbidden while it is open. The plan is to journal enough channel state that a fresh worker resumes both ends mid-stream. That needs a replay story for a partially-consumed stream, which items were delivered and which pulls answered, so it comes after the rest of the durable work.

Type rules in metadata. Two nodes want an output whose type metadata cannot write down as one type: FirstInOrder emits the union of its created inputs, and LlmInference.response is a String without parseJson and a record or JsonDict with it. Both make the author restate the type in the signature every time. The plan is a small rule language in metadata.json, read the way portsFromConfig is, for “the union of these inputs” or “this type when that input is true”. It is the whole feature or nothing, because a half rule would send the editor, the validator and the runtime three different answers about one port.

More the compiler can prove

Today the compiler checks how the pieces connect: types, required inputs, graph shape. The ambition is for it to prove things about how a program behaves, through richer node metadata. A node that acts in the world says so and says how big the action is. A node that verifies says what strength it gives. An autonomous node says so. Then the compiler can check a claim across the graph: every path from an autonomous step to a high-stakes action passes through a verification of at least this strength, or a critical action has three separate human approvals in front of it, and a program that does not hold up does not build. None of it works if the labels are decoration, so the other half is vetted sets of nodes, signed off by whoever is doing the regulating, with safety-critical programs required to build out of those. Read How I think about AI safety for why this matters.

Running it for real

Other clouds. Weft installs on GCP today (deploying to your cloud). AWS and Azure come next, installed on your own account the same way.

The builders

Tangle’s own CLI. Tangle today is a set of instructions on top of whatever coding assistant you use. A proof-of-concept CLI wrote a working program from a brief in under five minutes. The released version is slower because existing assistants spend time on fluff weft does not need, and our own CLI fixes that.

Coordinated assistants in parallel. Several agents building branches of the weft codebase under one plan, the same shape weft gives a program.

The editor

A version tree in the sidebar. weft tree already knows every version, what changed, the runs beneath each, and head. The sidebar shows none of it, so you cannot see that two runs sit on different branches or that head moved back. The plan is a git-client-shaped history: a row per version, a lane per branch, the runs nested under their version, with branching and seed selection on the same view.