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 use | Pass |
|---|---|
| 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 directory | What it is |
|---|---|
src/main.weft | The program |
weft.toml | Its 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 is | You start it with | |
|---|---|---|
| Infrastructure | Containers your program asked for: a database, a model server, anything that has to keep running | weft infra start |
| Triggers | The listening: a webhook waiting, a timer ticking, a form open for answers | weft activate |
| Executions | One run of the graph, start to finish | weft 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 box | What 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 glow | Reused from an earlier run instead of run again |
| Dimmed, no glow | Outside 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.jsonbefore 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.
| Path | What 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>/skip | Somebody skipping that waiting step |
GET /signal-token/signals | A client holding a signal token, listing the steps waiting on it |
DELETE /signal-token/signals | That 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/health | That client checking its token still works |
GET /signal-token/displays | That 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>/action | That 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/callback | A 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.png | A 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.
-
In the Cloudflare dashboard, create a tunnel (Zero Trust, then Networks, then Tunnels) and copy its token.
-
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. -
Install with both set:
WEFT_PUBLIC_TUNNEL_TOKEN=<token> WEFT_PUBLIC_TUNNEL_HOSTNAME=https://weft.example.com ./setup.sh --public-urlThe 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 happened | What the step does |
|---|---|
| A required input closed | Skips |
| An optional input closed | Nothing. The other inputs decide |
| Every input closed | Skips |
Every @require_one_of input closed | Skips |
_should_flow is false, or closed | Skips |
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
| Key | What 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.approved | Decides whether the step runs. Takes true, false, or an arrow |
_should_not_flow: decision.sent | The 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]
| Type | What it holds |
|---|---|
String, Number, Boolean | The obvious |
Null | Ordinary data. A port whose type allows it takes it like any value |
Image, Video, Audio, Blob | A stored file. Blob is anything that is not the other three: a PDF, a zip |
Media | Short for Image | Video | Audio |
File | Short for those three plus Blob |
List[T] | Any number of T |
Dict[K, V] | Keys and values, all the same type |
JsonDict | An object whose values nobody checked |
{ a: String, b?: Number } | A record, with ? marking a field that may be missing |
A | B | Either one |
Access | A connection to a service. Go and read how connections work |
Generator[T] | A stream of T. Go and read streams and buses |
Bus | A live channel. Same page |
Empty | Nothing can be one. See below |
MustOverride | Nobody has decided yet, and the build stops until you do |
T, T1 to T99 | A 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
| From | Into | Fits? |
|---|---|---|
String | String | Yes, and the same for every other primitive |
List[A] | List[B] | If A fits B |
List[T] | T | No. To go through a list one at a time, put the consumer in a Loop |
Record | JsonDict | Yes. A record forgets itself |
JsonDict | Record | No. Use a Cast, which checks the shape |
Dict[String, V] | JsonDict | Yes, and back |
Record | Record | Every field you have must be declared on the target, every required field it wants must be required on yours, and the types must match |
A | A | B | Yes |
A | B | A | Only if both A and B fit A |
Generator[A] | Generator[B] | Only if they fit both ways. A stream is invariant |
Empty | anything | Yes |
| anything | Empty | No |
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.
| From | Into | What happens |
|---|---|---|
String | Number, Boolean | Parsed |
String | List, Dict, JsonDict, a record, a named type | Parsed as JSON, then held to the target’s shape |
| Anything not a file or a live handle | String | Turned into text |
Number | Boolean | 1 and 0 |
| Any object shape | A record or a named type | Checked against the declared structure at run time |
| Any object shape | A file type | The 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.
| Type | Starts as |
|---|---|
Number | 0 |
String | "" |
Boolean | false |
List[T] | [] |
Dict, JsonDict | {} |
| A union | The zero of its first variant that is not Null |
| Everything else | null |
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
| Thing | Crosses? |
|---|---|
| Ordinary values, connections, file markers, bus markers | Yes |
| A closure | Yes, and it cascades to whatever inside needed the value |
_should_flow and _should_not_flow | No. Consumed at the boundary |
A stream (Generator[T]) | No |
| A wire from inside to outside that skips the interface | No |
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.
| Setting | Default | What 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: true | false | Launch every iteration at once, rather than one at a time |
max_iters: 50 | no cap | A hard limit. 0 is legal and means no iterations |
trim_on_mismatch: false | true | With 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.
| Kind | Outside the loop | Inside the loop |
|---|---|---|
Iterated, named in over | List[T] or Generator[T] | One T, a different one each iteration |
| Broadcast, any other input | T | The same T, every iteration |
Gathered, any output not in carry | List[T | Null], one slot per iteration | You write one T per iteration |
Carried, named in carry | The seed goes in, the final value comes out | The 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 consumers | Two 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 boundary | It 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 default | There is no such thing as an empty stream sitting unwired. A stream input is required |
| Sit inside a list, dict, record or union | It is a port type, not a value |
Be a loop’s carry | A carried value goes through the journal as JSON between iterations, and a stream is a live thing |
| Be stored, cast, or turned into text | Same 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 default | Ephemeral | |
|---|---|---|
| What the record keeps | Every message | Counts per sender, and nothing else |
| A reader that falls behind | Can reach back as far as the window | Skips to the oldest message still held |
| If the record cannot be written | The send is refused before anything is appended | Nothing 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 want | Use |
|---|---|
| One node’s output, arriving in pieces | A stream |
| Several live nodes talking to each other | A bus |
| To fan one thing out to several readers | A bus |
| Ordering and type checking on every item | A stream |
| To survive the worker restarting | Neither. 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
}
| Input | Default | Change it when |
|---|---|---|
durable | off | a run has to carry on after its worker dies, instead of ending |
recorded | on | a page asks a route for something every few seconds (turn it off) |
outlivesCaller | off | a run answers its caller early and keeps working (only on a trigger that holds a caller on the line, like Route and Socket) |
keepRunsFor | the project’s, else a week | you 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 |
holdSecs | 60 | a 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 trigger | Needs a public address? |
|---|---|
| A timer or a schedule | No |
| Something weft polls | No |
| A socket weft dials out to | No |
| A form somebody fills in | No |
| A provider pushing events at you | Yes |
A Route or Socket that strangers call | Yes |
./setup.sh --public-url opens one. Go and read
a public address.
What a trigger cannot do
| Why | |
|---|---|
| Be wired from another trigger | A trigger’s inputs are frozen at setup, so another trigger’s output could never reach it |
| Have an infra node downstream | Provisioning happens before any event exists |
| Sit inside a loop | It 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 in | Writes back? | |
|---|---|---|
@include("triage.weft") | Another weft file, as one group | No. The editor opens that file instead |
@file("assets/prompts/triage.md") | A file’s text, as a value | Yes. Editing the field edits the file |
@asset("assets/logo.png", Image) | A file, a URL or a stored file, as a value | No. 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
| Slug | What it found | What to do |
|---|---|---|
parse | The source did not parse | Read the message; it names the span and the syntax that failed |
enrich | A node’s ports could not be worked out: a bad type, or a custom port on a node that does not take them | Fix the port declaration on the node named |
unknown-type (warning) | A node names a type the catalog does not have | Check 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
| Slug | What it found | What to do |
|---|---|---|
unknown-source-node | A wire reads from a node that does not exist | Fix the name |
unknown-target-node | A wire writes to a node that does not exist | Fix the name |
unknown-source-port | A wire reads a port the source does not declare | Fix it; the message lists the real ports |
unknown-target-port | A wire writes a port the target does not declare | Same |
duplicate-input-port | Two wires land on one input | Remove one; the message names the first one’s line |
double-driven-port | An input is both wired and written into the body | Remove one of the two |
scope-reachability | A wire crosses a group or loop boundary without going through its ports | Route it through the group’s own ports |
deref-path | The dotted path on a wire cannot be read off the source’s type | Fix the path, or change the source type |
type-mismatch | The source’s type does not fit the target’s | Change one side, or cast. The message suggests a cast where one exists |
must-override-unmet | A connected port is still MustOverride | Declare a real type in the source |
unresolved-typevar | A connected port still has a type variable nothing pinned down | Connect it to something concrete, or declare the type |
cast-not-allowed | A cast node’s two ports are a pair the conversion table does not allow | Pick types the table allows |
graph-cycle | The wires form a cycle | Iterate with a Loop, or exchange feedback over a bus |
required-port-unmet | A 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
| Slug | What it found | What to do |
|---|---|---|
config-type-mismatch | A written value does not fit the input’s type | Fix the value, or the declared type |
input-accepts | The port does not take this kind of driver: a wire on a literal-only port, a written value on a wire-only port | Use the one it takes; the message lists them |
gate-not-boolean | A written _should_flow or _should_not_flow is not a Boolean | Write true or false, or wire it |
literal-out-of-range | A written number is outside the input’s min and max, or off its whole-number step | Write a value inside the declared range |
literal-not-an-option | A 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 it | Write one of the options; the message lists them |
value-on-output | You wrote a value for an output port | Remove it. Outputs are filled by the node, and you read them with node.port |
undeclared-port-no-custom | A key names no input, on a node that does not take added inputs | Fix the key, or use a node with canAddInputPorts |
config-null-literal (warning) | A key is written key: null, which does nothing | Leave the key out and let the default apply |
require-one-of-unknown-port | @require_one_of(...) names a port the node does not have | Fix it; the message lists the real inputs |
require-one-of-unmet | None of the ports in a @require_one_of group is driven | Drive at least one |
Names and shape
| Slug | What it found | What to do |
|---|---|---|
duplicate-node-id | Two nodes share an id once the graph is flattened | Rename one; the message names the first one’s line |
duplicate-port | A node has two ports of the same name on one side | Rename one |
reserved-name | A node or group is named after a node type, so name.port would read as an inline node | Rename it |
two-gates | A node carries both _should_flow and _should_not_flow | Keep 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 null | Mark one required, or add @require_one_of |
level-too-large (warning) | One level of the graph holds more than fifteen items | Group 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.
| Slug | What it found | What to do |
|---|---|---|
config-ports-not-a-list | The key holding those entries is not a list | Write it as a list |
config-entry-not-an-object | One entry is not an object | Write it as an object |
unknown-config-entry-kind | An entry’s kind is not one this node offers | Use one the message lists |
config-entry-without-a-port | An entry does not say which port it adds | Add the key naming the port |
unknown-config-entry-key | An entry carries a key that kind does not take | Remove it, or use one the message lists |
config-entry-bad-value | An entry’s value is the wrong shape, or a value the matched input could never hold | Fill in a value of the right shape |
config-entry-missing-value | An entry is missing a value that kind requires | Add the key the message names |
duplicate-catch-all | Two entries match anything, so the second is unreachable | Keep one |
catch-all-not-last | Entries sit after a catch-all, where nothing can reach them | Move the catch-all last |
Loops
| Slug | What it found | What to do |
|---|---|---|
loop-unknown-config-field | A key that is not parallel, over, carry, max_iters or trim_on_mismatch | Fix the name |
loop-parallel-not-boolean | parallel is not true or false | Write one of them |
loop-max-iters-not-integer | max_iters is not a whole number of zero or more | Write one |
loop-trim-not-boolean | trim_on_mismatch is not a boolean | Write true or false |
loop-over-unknown-port | over names an input that does not exist | Fix the name |
over-not-a-list | An over port is neither List[T] nor Generator[T] | Declare it as one |
loop-carry-unknown-port | carry names an output that does not exist | Fix the name |
carry-port-type-mismatch | A carry port’s two sides have different types | Make them the same |
over-and-carry-overlap | A port is in both over and carry | Pick one role |
parallel-with-carry | parallel: true with carry ports. Carrying means each turn feeds the next, which is sequential | Drop the carry, or drop the parallel |
parallel-without-over | parallel: true with nothing to spread out | Give it a non-empty over |
parallel-with-done | A parallel loop writes self.done | Remove it, or make the loop sequential |
loop-unbounded-no-termination | A sequential loop with no over, no max_iters, and no self.done. Nothing would ever stop it | Iterate a list, cap the count, or wire self.done |
gather-output-must-be-nullable | A gather output is not List[T | Null]. An iteration that failed leaves an empty slot | Declare it List[T | Null] |
reserved-port-name | A loop boundary declares index or done, which the language owns | Rename the port |
loop-boundary-unpaired | A LoopIn without its LoopOut, or the reverse | This one is a compiler bug, not yours. Please report it |
loop-config-missing-parallel | The loop’s config did not come through lowering | Also a compiler bug. Please report it |
Streams
| Slug | What it found | What to do |
|---|---|---|
generator-in-container | Generator[T] nested inside a list, dict, record or union | A stream is a port type. Declare it at the top level of the port |
generator-input-must-be-required | A stream input is optional or has a default. A stream has no empty value | Mark it required and drop the default |
generator-multiple-consumers | A stream output feeds more than one input. A stream has one taker | Wire it to exactly one. To broadcast, use a Bus |
generator-through-group | A stream crosses a group or loop boundary | Keep the producer and the consumer in the same scope. A loop takes a stream only through its over |
generator-into-generic-port | A stream is wired into a port whose type is unresolved and not a stream | Declare the target Generator[T], or consume the stream and emit values |
generator-not-iterated | A loop takes a stream that is not in over. A stream cannot broadcast into a body | Add it to over |
generator-not-carriable | A carry port is a stream. A stream is a live edge and cannot cross iterations | Carry a value instead |
over-stream-not-alone | A stream in over beside other over ports | Make it the only one, and zip upstream if you need more |
Triggers, infrastructure and addresses
| Slug | What it found | What to do |
|---|---|---|
run-settings | A trigger’s durable, recorded or outlivesCaller is not true or false, or it is durable or outlives its caller and is not recorded | Write true or false; turn recorded back on, or the other one off |
trigger-into-trigger | A trigger is wired into another trigger | Remove the wire. A trigger’s inputs are frozen at setup, so another trigger’s output could never reach it |
trigger-into-infra | An infra node sits downstream of a trigger | Remove the wire. Provisioning happens before any event exists |
trigger-in-loop | A trigger inside a loop body | Move it out. A trigger registers once and fires outside any iteration |
infra-in-loop | An infra node inside a loop body | Move it out. Infra is provisioned once for the project, not once per item |
route-path-missing | A node claiming a public address has none, or an empty one | Give it a path, like hooks/stripe or chat/{room} |
route-path-invalid | The path is not a servable pattern | The message says what is wrong |
route-method-unknown | That is not an HTTP method | Fix it |
route-overlap | Two nodes claim addresses one call could reach, and neither is more specific | Make one spell out what the other captures, or change a path or a method |
Instances
| Slug | What it found | What to do |
|---|---|---|
per-instance-ineligible | @per_instance on a node that runs no container of its own. Only an infra node can carry it | Remove it; for a value each instance gets (its connection included), write @instance_filled on the field |
instance-filled-wired | An @instance_filled field also has a wire into it | Remove the wire, or the marker |
instance-filled-boundary | @instance_filled on a group’s or included file’s own port | Write 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 inputs | Put it on an input |
For what @per_instance and @instance_filled do, go and read Programs with instances.
Types you named
| Slug | What it found | What to do |
|---|---|---|
named-type-conflict | A named type appears with two different bodies | Make every use of the name carry the same body |
Rules a node declared for itself
| Slug | What it found | What to do |
|---|---|---|
rule-structural | A rule the node’s own author wrote, checked on every build | Whatever its message says |
rule-runtime | The same, but only checked by weft validate and just before you run | Fill 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.
| File | Needed | What it is |
|---|---|---|
metadata.json | Yes | The declared surface: ports, types, settings. Go and read metadata.json |
mod.rs | Yes, to run | The Rust: one impl Node |
deps.toml | No | Extra crates, system packages, build environment |
tests.rs | No | Its own tests. Go and read testing a node |
images/<name>/Dockerfile | Infra nodes only | A 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
| Key | Type | Default | What it does |
|---|---|---|---|
type | String | required | The node’s catalog id. Has to be a valid Rust identifier |
label | String | required | The name shown in the editor |
description | String | required | One line, shown to people and given to the AI builder |
tags | List[String] | [] | Search words for the node picker |
icon | String | none | A lucide icon name |
color | String | none | Hex, a CSS variable, or a palette token |
inputs | List | [] | Everything the node takes, wired data and written settings alike |
outputs | List | [] | The ports it emits on |
requires_infra | Boolean | false | The node provisions containers and must implement provision_infra |
images | List[String] | [] | Directories under the package root, each holding a Dockerfile to build |
publishes | String | none | The service whose connection this node hands out itself. Needs an Access output too |
features | Object | all off | Small flags about what the node is |
display | Object | none | What to render on the node’s body after a firing |
validate | List | [] | Rules the compiler checks against the graph |
portsFromConfig | Object | none | Says this node’s ports come from a list in its own config |
types | Map | {} | Named types this node contributes to the project |
firesWith | Map | {} | For a trigger: the shape of the event it wakes with |
claimsRoute | Object | none | Which config fields hold the public address this node claims |
accessApps | Map | {} | Public OAuth apps this node ships, by service |
service | Object | none | The connect recipe, on an access node. Go and read declaring a service |
inputs
| Key | Type | Default | What it does |
|---|---|---|---|
name | String | required | The port name, unique among inputs |
type | type string | required | What it takes |
required | Boolean | false | A firing with nothing here skips the node. Write it only when true |
accepts | List | both | ["wire"] refuses a written value, ["literal"] refuses an arrow. A Bus or Generator port is wire-only whatever you say |
widget | Object | from the type | The editor control |
default | any | none | What the runtime supplies when nothing drives the input. Never written into source |
label | String | the name | The field’s name on the form |
placeholder | String | none | Hint text in the empty field |
description | String | none | A sentence next to the field |
requiresScopes | List[String] | none | Permissions this consumer needs on the wired connection. Access inputs only |
requiresValues | List[String] | none | Stored values it needs. Access inputs only |
outputs
| Key | Type | Default | What it does |
|---|---|---|---|
name | String | required | The port name |
type | type string | required | What it emits |
description | String | none | A sentence next to the port |
baked | Boolean | false | On 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
| Key | Type | Default | What it does |
|---|---|---|---|
isTrigger | Boolean | false | This 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" | none | On 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" | none | This 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 |
calledFromOutside | Boolean | false | On 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 |
catchErrors | Boolean | false | Set 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 |
pure | Boolean | false | Set 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 |
oneOfRequired | List[List[String]] | [] | Each inner list is a group where at least one port must arrive, or the node skips |
canAddInputPorts | Boolean | false | Source may declare extra inputs on it |
canAddOutputPorts | Boolean | false | Source may declare extra outputs |
optionalCustomInputs | Boolean | false | Ports people add are optional rather than required |
customInputType | type string | none | The type every added input takes. Naming one variable makes them all one type |
showDebugPreview | Boolean | false | Render the last output as JSON on the node body |
liveEndpoint | String | none | The declared endpoint the dispatcher proxies /live to |
castPorts | {input, output} | none | Declares the node a checked cast between those two ports |
hidden | Boolean | false | Keep it out of the picker and out of describe-nodes |
display
| Key | Type | What 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 |
input | String | Show this input’s value |
output | String | Show this output’s value |
Exactly one of input and output, never both and never neither.
widget
Every widget, by its kind:
kind | Other keys | What it draws |
|---|---|---|
text | One line of text. The default for String | |
textarea | A wrapping box. The default for anything JSON-shaped | |
code | language | A 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 |
number | min, max, step | A 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 |
checkbox | A tick box. The default for Boolean | |
datetime | A date and time picker, stored as ISO 8601 | |
select | options, free_text | A 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 |
multiselect | options, free_text | Multiple choice over a List[String], each item held to options the same way, unless free_text is set |
text_list | Short strings added one at a time. The default for List[String] | |
password | A masked box | |
file_drop | accept, type, multiple | A file picker that writes an @asset(...) reference |
entry_list | Builds the config list this node’s ports come from. Only legal on the input portsFromConfig names | |
access | The connection picker. Write just {"kind": "access"}: the service and whether it is optional are filled in from your service recipe | |
remote_select | access, sources, depends_on, free_text | Pick 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
kind | Keys | Where the options come from |
|---|---|---|
granted | from, label, value | The connection row itself, recorded at sign-in. Costs no call |
list | a Lookup, plus requires | Calling the service and enumerating |
picker | script, code, grants, mime_types | The provider’s own chooser, opened on a weft page |
from_url | pattern | The 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:
kind | Keys | What it asks |
|---|---|---|
input_satisfied | port | Does this input have a value, from a wire or written in |
input_wired | port | Does it have a wire specifically |
output_wired | port | Does anybody downstream read this output |
input_source_type | port, equals | Do all wires into this port come from that node type |
config_present | field | Is this field set to anything non-null |
config_nonempty | field | Is it set and not blank or empty |
config_equals | field, equals | Does it equal this exact value |
config_in_set | field, values | Is its string one of these |
config_matches | field, regex | Does its string match. False if absent, not a string, or the regex is broken |
custom_outputs_declared | Did the source add outputs beyond the metadata’s own | |
run_reaches | direction, with | Does 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_of | with | Is a node with a feature wired upstream of this one, so it runs first. {with} in the message names those nodes |
per_instance | Does 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_names | port, names | Is 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 / any | of | Every one, or at least one |
not | of | The 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.
| Key | Default | What it does |
|---|---|---|
field | required | The config key holding the list. Must be a declared input |
matchInput | none | The input the entries are matched against, when only one entry wins |
specs | required | The 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 wrote | What to do now |
|---|---|
inputs[].exposure | Use accepts. ["wire"] refuses written values, ["literal"] refuses arrows, and leaving it out allows both |
outputs[].required | Remove it. An output has no optionality |
features.isOutputDefault | Remove 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
| Field | Type | What it is |
|---|---|---|
execution_id | ExecutionId (a UUID) | This run’s id |
project_id | Uuid | The project it belongs to |
node_id | String | This node’s id in the graph |
node_type | String | Its catalog type, such as ExecPython |
node_label | Option<String> | The title shown on the box, if it has one |
frames | LoopFrames | Which 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.
| Call | What you get | It fails when |
|---|---|---|
get::<T>("name") | A required value | It is absent, or it does not fit T |
opt::<T>("name") | Option<T>; absent or null gives None | A value is there and does not fit T |
get_or::<T>("name", default) | The value, or your default | A value is there and does not fit T |
list::<T>("name") | Vec<T>; absent gives [], and one value gives a one-item list | Any item does not fit T |
access("name") | The connection picked on an access input | Nobody picked one and the service requires it |
raw("name") | Option<&Value>, the JSON as it arrived | never |
object() / record() | The whole bag as one record | On a wake bag whose event was missing or not an object |
nested("name") | The object under name, as its own bag | The value is there and is not an object |
iter() | Every named value | never |
custom() | Only the ports this node added in the program, not the type’s own settings | never |
declared() | Only the type’s own declared settings | never |
in_order() | Every input that arrived, in source order | On a wake or nested bag |
declares("name") | Whether the node declares this input at all | never |
for_holes(holes, what, spell, declare) | Values matched to named holes in a text, such as $user_id in SQL | A 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
| Call | What it does | It fails when |
|---|---|---|
pulse_downstream(output).await | Sends values on | A 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).await | The same, but waits until the value was taken | Same, plus a delivery that can never happen, and an empty output |
close_port("port").await | Closes one output. On a stream port this is the end of the stream | The 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 4096 | The 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 data | never |
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 output | The 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 type | never |
is_output_wired("port") | Whether anything downstream reads that output in this run | never |
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:
| Call | What 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
| Call | What it does | It fails when |
|---|---|---|
await_signal(kind).await | Parks this firing until the signal fires, releasing the worker. Returns the payload | The body already emitted, or the node has a stream input, or the body’s call order changed between replays |
register_signal(kind).await | Sets up a trigger that starts a new run on every fire. Called during setup | Called twice for one node in one setup |
run("name", closure).await | Runs the closure and writes down its result, or gives back what was written down last time | The 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
| Call | What you get | It fails when |
|---|---|---|
open(&access).await | The connection, leased for this firing, with its token refreshed | It needs reconnecting, the service does not match, or the door refuses |
open_within(&access, window).await | The same, with a window you choose instead of the default 15 minutes | Same |
client(access).await | Straight to the signed-in HTTP client. Takes None and gives you a plain one | Same, when it is Some |
http() | The shared plain HTTP client, no credentials | never |
publish_access(values).await | Publishes a connection to something this node runs itself, such as a database it provisioned | A field the service does not declare, or a required one missing |
published_access().await | What this node published before, or None | The broker cannot answer |
endpoint("name").await | A handle on one of this node’s declared infra endpoints, once something answers there | The 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.
| Scope | Where it lives, and how long |
|---|---|
Execution (the default) | This run only, swept after it ends unless you keep it |
Project | Outlives 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 |
Asset | The project’s published assets. Readable, and the worker refuses writes |
| Call | What 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
| Call | What it does | It fails when |
|---|---|---|
create_bus(opts) | Makes a bus and gives you a handle and a marker to emit | The options do not make sense |
bus(&marker) | Turns a marker value back into a live handle | The bus is gone, or that is not a marker |
open_bus("port", opts, "name").await | The whole producer move: make it, emit the marker, register your name. Closes on drop | Emitting or registering fails |
join_bus("port", "name") | The consumer twin, also closing on drop | The input is not a live bus |
bus_from_input("port") | A handle that does not close the bus. For an observer | Same |
Read a stream input like any other value: ctx.inputs.get::<Generator<Row>>("rows")?.
| Call | What it does |
|---|---|
next().await | The next item, waiting. None once, at a clean end |
try_next() | Item, Empty or Finished, without waiting |
drain().await | Everything, 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
| Call | What 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().await | The HTTP caller, attached and connected, or a loud error |
ws_caller().await | The same for a WebSocket |
live_caller().await | Whichever one is connected |
caller_request() | What the caller sent to open the exchange, without waiting for the connection |
Logging, tags, cancelling
| Call | What it does | It fails when |
|---|---|---|
log(level, message).await | Writes a line into the run’s log. Trace, Debug, Info, Warn, Error | The write fails |
tag_execution(["user_7"]).await | Tags 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).await | Queues a stop for every live run of this project carrying that tag, turned into a tag the same way | The tag is empty |
is_cancelled() | Whether this run was cancelled. Cheap enough to poll | never |
cancellation() | The flag itself, to select! against your own work | never |
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.
| Call | What it does |
|---|---|
infra("bridge").start().await | Brings 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).await | Scales the copy down, keeping its disk |
infra("bridge").terminate(spec, stop_self).await | Deletes the copy and its disks, except the ones listed in keepOnTerminate |
infra("bridge").instance(id).wipe(spec, stop_self).await | Deletes the instance’s copy and every one of its disks, the kept ones too, even if the copy was already terminated |
infra("bridge").status().await | The 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().await | Every copy: the shared one and each instance’s |
trigger("receive").activate().await | Turns one trigger on |
triggers().deactivate(spec, stop_self).await | Turns 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().await | The 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().await | Gives 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().await | Forgets 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().await | Every 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().await | The 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).await | Deletes runs; runs still going follow running |
runs()...list(limit).await / .count().await | The 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).await | An instance token, value shown once |
tokens().instance(id).revoke().await | Revokes 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:
| Rank | Source |
|---|---|
| 1 | A value that arrived on a wire |
| 2 | A value written into the node’s body, if nothing arrived |
| 3 | The 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 want | Write |
|---|---|
| To wrap somebody else’s error | result.node_err("reading the reply")? |
To say what was missing when an Option was None | option.node_err("the response had no body")? |
| To fail on something you worked out | weft::node_bail!("pick a channel or a user") |
| The same, as an expression | node_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:
| From | Into |
|---|---|
String | A number, a boolean, or any object shape, parsed as JSON and then checked |
| Anything that is not a file or a live handle | String |
Number | Boolean, and back |
| Any object shape | A 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" }
kind | What you get |
|---|---|
media | The 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 |
link | Always 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.
type | data | Drawn as |
|---|---|---|
text | a string | A box you can copy from |
image | a string an <img src> takes, so a URL or a data: URI | The picture |
progress | a number from 0 to 1 | A bar |
secret | a string | Masked 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.
| Scope | Lives | Gone when |
|---|---|---|
Execution (the default) | This run | Five minutes after the run ends, so its output is still downloadable, unless you kept it |
Project | Across runs of this project | weft clean or weft rm, or once its lifetime runs out if you gave it one |
Shared { name } | Across projects that name the same space | Explicit removal, or once its lifetime runs out if you gave it one |
Asset | The project’s @asset copies | Readable 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?;
KeepTtl | Meaning |
|---|---|
Default | 30 days |
Secs { secs } | That long |
Never | No 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
| Call | Use 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
| Call | Gives 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
| Call | The 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 type | For |
|---|---|
Image | image/* |
Video | video/* |
Audio | audio/* |
Blob | Everything 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 answered | Returns the answer straight away, without waiting |
ctx.await_signal that was given up | Fails the same way again, without waiting |
ctx.run("name", ...) that finished | Returns what it returned last time, without running the closure |
| Anything else in your body | Runs again |
| Anything you emitted | Runs 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
| Kind | What it is |
|---|---|
Timer | A 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 |
Form | A person filling something in |
Route | An inbound HTTP request |
Socket | An inbound WebSocket |
SocketListen | A socket weft dials out to and holds |
StreamListen | A TCP or TLS stream |
SseSubscribe | A server-sent-event stream weft subscribes to |
PollEndpoint | An 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 |
ProviderEvents | A 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
| Call | What 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
| Call | Gives you | Fails when |
|---|---|---|
ctx.http_caller().await? | An HTTP caller, attached and connected | There is none, or it is a WebSocket |
ctx.ws_caller().await? | A WebSocket caller | There is none, or it is HTTP |
ctx.live_caller().await? | Whichever is connected | There 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?;
| Call | What 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?;
}
| Call | What 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)
| Option | Default | What it decides |
|---|---|---|
ephemeral | false | Whether payloads are recorded, or only counts |
window | 64 messages | How far back a reader can reach |
payload | JSON | JSON or bytes. Frozen at creation; sending the other shape is refused |
journal_window | 1 second | How often the record is written |
meta | nothing | Details 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::Keep | Stop the others, keep running |
StopSelf::Include | Stop 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
| Field | What it holds |
|---|---|
units | What runs. Most nodes have exactly one |
volumes | Disks, kept across stop and upgrade by name, and scratch space emptied at each start |
endpoints | Named ports your node and the project’s other nodes reach it on, and whether anything outside the project may |
keepOnTerminate | Disks 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.
| Call | Gives 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::SameNetworkopens 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");
| Call | What 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
| Tier | What it gets | Costs |
|---|---|---|
basic | Nothing. A plain function testing your own logic | Nothing |
fake | Canned HTTP, in-memory storage, canned signals, fake connections | Nothing |
live | The real runtime: real connections, real calls, real metering | Real 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 want | Call |
|---|---|
| Run the node | rig.run(&node, json!({...})) |
| Run its trigger setup, or its infra | rig.run_setup_trigger(...), rig.run_provision_infra(...) |
| Canned HTTP | rig.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 all | rig.fail_connection(method, path): the call errors with no status and no body |
| Check what it sent | rig.requests(), rig.assert_sent(method, path) |
What ctx.run recorded | rig.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 connection | rig.access("slack"), rig.connection_value(...), rig.connection_permissions(...) |
| A trigger’s event | rig.wake(payload), rig.signal(payload) |
| A live caller | rig.attach_caller(conn) |
| A bus going in | rig.seed_bus(opts), giving you a writer and a marker |
| A bus it emitted | rig.bus(&outcome.outputs["channel"]) |
| A stored file | rig.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 endpoint | rig.declare_endpoint(name, url), rig.answer_endpoint(...), rig.refuse_endpoint(...) |
| What it asked for | rig.registered_signals(), rig.awaited_signals(), rig.logs(), rig.execution_tags(), rig.stops() |
| A port’s type, for a node that takes added ports | rig.output_type(port, ty), rig.input_type(port, ty) |
| An output something downstream reads | rig.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 socket | rig.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:
| Signature | What 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) -> Value | A 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) -> u16 | A 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>>) -> Value | Stores 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>>) -> Value | The same in another scope |
Running it:
| Signature | What it does |
|---|---|
run(&self, node: &dyn Node, inputs: Value) -> RunOutcome | Runs the node’s body with inputs (a JSON object of input name to value) |
run_setup_trigger(&self, node: &dyn Node, inputs: Value) -> RunOutcome | Runs a trigger’s setup |
run_provision_infra(&self, node: &dyn Node, inputs: Value) -> RunOutcome | Runs an infra node’s provision_infra |
Looking at what happened:
| Signature | What 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
| Signature | What it does |
|---|---|
access(&self, service: &str) -> Value | The 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 method | What 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 method | What it is |
|---|---|
method: String | Uppercase, "POST" |
path: String | The 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: bool | True 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:
| Field | What it is |
|---|---|
endpoint: String | The endpoint’s name |
method: EndpointMethod | Get or Post |
path: String | The 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"
| Section | What 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 is | Who writes it | |
|---|---|---|
| The recipe | How a credential is obtained, how a request is signed, what the permissions are, how events arrive. Pure declaration, no code | Whoever wrote the access node |
| A registered app | One OAuth application: its client id, its secret, the permissions it asks for | The operator, in the apps file |
| A connection | One account somebody connected. Its sealed values, its permissions, who it belongs to | The person who connected it |
The Access value | The reference your program passes around | The 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
| Door | What it is |
|---|---|
shared | One click on a credential weft holds: a registered application, or the runtime’s own key, spending its credit |
own | You 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 does | Is it authoritative? |
|---|---|
| States exactly what it granted | Yes |
| Lets weft ask the credential to describe itself | Yes |
| Confirms only that the credential is alive | No |
| Says nothing at all | No |
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
| Key | What it is |
|---|---|
kind | "api_key" |
label | The name the connection list shows |
key | The 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": "..." }
}]
}
| Key | What it is |
|---|---|
label | Unique within the service |
covers | Exactly the permissions this app asks for, by their ids from the service’s catalogue |
auth_url, token_url | The provider addresses this app signs in against, copied from the recipe |
client_id, client_secret | From the provider. client_secret is absent for a public PKCE app |
events | Only 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_keyfor a service - An OAuth app with no
covers - A permission in
coversthat the service’s catalogue does not have - An
eventsblock 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:
| Call | Use 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.
| Call | Gives 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
| Call | Gives 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
| Key | Default | What it says |
|---|---|---|
service | required | The name, lowercase letters, digits and underscores |
label | the service name | What people see, like “Slack” |
acquisition | required | How 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 |
test | none | A call made at connect time to check it works, and where identity usually comes from |
identity | none | The display name, assembled from stored values, like "{team} / {user}" |
permissions | [] | The permission catalogue people tick from |
all_permissions_url | none | Where the provider’s full list is, when yours is a subset |
verification | silent, free | What the check can learn and what it costs |
connection_optional | false | The node runs with nothing picked |
callback_https | false | The provider refuses plain http callbacks |
own_page | none | What the “your own” door shows beyond its paste fields |
events | {} | How this service reports events, by topic |
verify | none | How a caller presenting this connection is checked, on a gated route |
capabilities | [] | Named groups of optional fields, each unlocking one thing |
grants | coexisting | Whether two grants of this service can coexist |
Acquisition
kind | For | Keys |
|---|---|---|
static | Paste a key | fields |
oauth2 | Sign in | grant, token_url, scope_delimiter, auth_params, extra_params, registration_fields, captures, token_auth, refresh |
mint_jwt | Sign your own assertion | fields, 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:
kind | What it does |
|---|---|
header | Adds a header |
query | Adds a query parameter |
basic | HTTP basic auth |
path_prefix | Puts a prefix on the path |
base_url | Replaces the scheme, host and port, keeping the node’s path |
sign | Signs 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:
rung | Meaning |
|---|---|
reports_permissions | The provider states what it granted |
self_introspect | The credential can describe itself |
reports_validity | Only “alive”, never “what” |
silent | Nothing 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:
| Key | What it is |
|---|---|
mint | A button that creates the application for them, if the provider has an API for it |
guide | Numbered steps, and a pre-filled creation link |
paste | The 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.
| Key | What it says |
|---|---|
fields | The named facts of one event, and where each lives in the payload. A dotted path, or header:X-Some-Header |
account | Which 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 |
socket | Present when the service offers dial out |
webhook | Present 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:
| Key | What it is |
|---|---|
verify | How a push is proved real: an HMAC, an Ed25519 or ECDSA signature, an API key, OIDC, or a token weft minted |
handshake | The one-off proof of ownership: echo a field from the body, echo a query parameter, or call a URL back |
event_path | Where the event sits inside the envelope |
route_by | Whether the event names the provider account, or an id weft minted |
decode | For an event wrapped in base64 or a form encoding |
subscribe | The 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.
| Method | Required | What it does |
|---|---|---|
service() | Yes | The service name, matching the access node’s |
base_url() | Yes | Where the provider really lives. The single authority, so no caller ever takes a host from a request |
classify(method, path) | Yes | What kind of route this is |
observe(path, query, body) | Yes | A fresh tap for one response |
resolve(path, observed, follow_up) | Yes | Price one observed call |
prepare(path, body) | No | Rewrite an outgoing body so the cost comes back reportable |
priceable(path, follow_up) | No | Asked before sending: can this be priced at all when it returns? |
opens_charge, charge_reported_on, fold_report | No | For a provider that reports the cost later, on a separate response |
observe_session(path, query) | For a session route | A fresh tap for one long-lived session, priced by its frames |
Routes
| Class | Meaning |
|---|---|
Billable | Spends money. Prepared, observed, priced |
Free | A known route that costs nothing: a model list, a health check, a cost lookup |
Reports | Not a charge itself. It speaks for one that is already open |
BillableSession | A session priced by its own frames as they flow |
Unknown | Not 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 --jsonalso detaches, so it starts the run and returns.weft activate --jsonrefuses rather than asking, when the project has preserved state waiting.weft connect --jsononly works with--list,--grant,--disconnectand--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
| Command | What 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 update | Re-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
| Command | What it does |
|---|---|
weft run | Compiles, registers, records a version, starts a run, and follows it |
weft run <example> | The same, with a saved example’s starting values |
weft build | Compiles and registers, starting nothing. Also how you put back code the dispatcher no longer holds |
weft bake | Prepares 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:
| Flag | What it does |
|---|---|
--detach | Start and return rather than following |
--referenced | Compile 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 |
--seed | Reuse 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 |
--root | Start 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 |
--durable | If 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 |
--fast | If 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
| Command | What it does |
|---|---|
weft executions | Past 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 status | The 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 ps | Every 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
| Command | What it does |
|---|---|
weft tree | The 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 examples | Saved examples, whether each is frozen, and its latest run |
weft prune <version> | Deletes a version, everything under it, and their runs |
Triggers
| Command | What it does |
|---|---|
weft activate | Sets 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 deactivate | Stops 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 resync | Deactivate 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-activate | Cancels an activate in flight |
weft cancel-build | Cancels a build in flight. The install stops the build, and the command that was building fails saying it was cancelled |
weft cancel-running | Ends 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:
| Flag | What it does |
|---|---|
--mode wipe|hibernate|park | What 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|wait | Cancel 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:
| Flag | What 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-instances | On weft deactivate only: every instance whose triggers are on, each with the same --mode. The program’s own triggers stay as they are |
Infrastructure
| Command | What it does |
|---|---|
weft infra start | Brings up whatever is down, and turns back on the triggers that came down with it (never one you switched off yourself) |
weft infra stop | Scales 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 terminate | Deletes 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 upgrade | Rebuilds 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 status | Where 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-doors | Which pieces you can reach from this machine, and at what address |
weft infra logs [<node>] | What the containers printed. --tail (200), -f |
weft infra cancel | Stops 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
| Command | What it does |
|---|---|
weft connect | Walks you through connecting an account |
weft connect --list | Prints 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 --disconnect | Clear 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|own | Which door to connect through, without being asked |
weft connect --set NAME=VALUE | Fill a credential field. The value is visible to other processes, so prefer the prompt or --set-env NAME=ENV_VAR |
weft token mint | Mint 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 ls | The 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-lib | Copies 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
| Command | What it does |
|---|---|
weft target add <name> <url> | Names an install in weft.toml. Replaces the address when the name exists |
weft target list | The 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 gcp | Writes .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 ls | The 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 list | Every domain, with its DNS record |
weft domain rm <name> | Stops answering at it; the last one takes the load balancer down |
weft workers | The 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
| Command | What 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 usage | How much is stored, and how many files |
The daemon
| Command | What it does |
|---|---|
weft daemon start | Brings the runtime to the state the code describes. Idempotent, so it is both first boot and refresh. restart is the same thing |
weft daemon stop | Stops the runtime and the workers it started. The database, your infrastructure and your data stay |
weft daemon status | Whether it is reachable, and the public address if one is open |
weft daemon logs | The runtime’s log. --tail (100), -f |
weft daemon remove | Takes 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
| Command | What 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 live | Calls 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
| Command | What it does |
|---|---|
weft describe-nodes --list | One line per node type: its name, its tags, the first sentence of what it does. The cheap first look |
weft describe-nodes --node <Type> --compact | One 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 parse | Parses source from stdin and prints the project, the catalog and the diagnostics as JSON. Lenient: unknown types become placeholders |
weft validate | The 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-server | A long-lived parse server, one JSON request per line, catalog held warm |
Cleaning up
| Command | What it does |
|---|---|
weft clean | Deletes runs older than --keep-days, 30 by default |
weft clean <execution-id> | Deletes one run |
weft clean --all | Deletes 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 --images | Reclaims worker images nothing references. --all spans every project |
weft clean --build-cache | Drops the docker build cache and the node-test cache. The next build compiles cold |
weft rm | Unregisters a project: signals wiped, runs cancelled and erased, infra terminated, stored data reclaimed |
weft rm --journal | Erases its runs one by one before unregistering it; weft rm erases them on its own too |
weft rm --local | Also wipes its build artifacts here |
weft rm --all | Both 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.
| Command | What it asks | The flag that answers it |
|---|---|---|
weft prune | Confirm the count of versions and runs | --yes |
weft rm | Confirm what will be removed | --yes |
weft clean | Confirm the runs to delete | --yes |
weft files rm | Confirm, naming how many files are kept ones | --yes |
weft connect --forget | Confirm | --yes |
weft infra terminate, weft infra node-terminate | Confirm what will be deleted | --yes |
weft connect | Which node, which door, which app, which permissions, the credential fields | --node, --door, --shared-app, --permissions, --set |
weft activate | What to do with preserved state | --reactivate-choice |
weft deactivate, weft resync, the infra verbs | Which preservation mode, and the hibernate grace | --mode, --grace |
weft test-node --tier live | Confirm 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
| Command | What goes |
|---|---|
weft rm | The project’s registration, its signals, its runs, its infra including disks, its stored data |
weft rm --journal | The same runs, erased before the registration goes |
weft rm --local | Also .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 --all | Every run |
weft clean --project <id> | That project’s entire history |
weft clean --build-cache | The docker build cache and the node-test cache |
weft files rm <space>/ | A whole space, including files you marked to keep |
weft deactivate --mode wipe | Every waiting signal, and suspended runs are cancelled |
weft activate --reactivate-choice wipe_all | Every parked signal and every pending suspension |
weft branch --discard | Your uncommitted changes |
weft catalog update | Anything you edited under nodes/base_catalog/ |
weft tangle update | Your edits to the Tangle files the template owns |
weft freeze <name> | The previous examples/<name>.json, replaced whole |
weft infra terminate | Every 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.
| Command | What it does |
|---|---|
weft infra start | Brings up whatever is down and leaves whatever is already up alone |
weft infra stop | Scales to nothing and keeps the disk. Start it again and your rows are there |
weft infra terminate | Deletes it, disk included, unless the step asked for the disk to be kept |
weft infra upgrade | Rebuilds against your current source. The triggers it took down come back once the new infrastructure is up |
weft infra status | Where each piece stands, with its address |
weft infra list-doors | The addresses this project’s infra answers on |
weft infra logs <node> | What the containers actually printed |
weft infra cancel | Stops 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://…
| Status | What it means |
|---|---|
provisioning | Coming up |
running | Every unit is ready |
stopped | Nothing running, disks kept |
flaky | It was healthy and has dropped below its readiness threshold |
failed | The 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 want | Run |
|---|---|
| My program should answer the world now | weft activate |
| Stop it answering | weft deactivate |
| I edited something a trigger reads, and it is live | weft resync |
| Prepare a trigger without exposing it yet | weft bake |
| Try one trigger with an event I made up | weft run --fire <trigger>='<json>' |
| I clicked activate and want it back | weft 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:
| Mode | Work that arrives meanwhile | What a person sees |
|---|---|---|
park | Nothing 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 back | Questions stay listed for the people who answer them, and an answer sent now waits too |
hibernate | The same as park for the grace window (--grace, in minutes). After it, nothing new is taken and the triggers stop listening | Questions are hidden for the whole time; someone who already has one open can still send their answer within the window |
wipe | Refused. Everything waiting is dropped and the runs waiting on the triggers are cancelled: a clean slate, and what you want while building | Questions 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 from | Command |
|---|---|
| You | weft run |
| One trigger, by hand | weft run --fire <trigger>='<json>' |
| A trigger firing for real | Nothing. It happens because you activated |
| A caller connecting | Nothing. The project has to be active |
| Status | What it means |
|---|---|
running | Work is happening now |
waiting_for_input | Parked on a person or a service. No worker is up and nothing is being spent |
completed | Finished |
failed | A 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 |
cancelled | You 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 changed | What to run |
|---|---|
| The shape of the graph | Nothing. The next run picks it up |
| A node’s Rust code | Nothing. The next run rebuilds it |
| What an infra step asks for | weft infra upgrade, then weft activate |
| Anything a trigger reads, on a live project | weft 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
keepRunsForinput (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 it | Not in it |
|---|---|
Every .weft file, at any depth | Anything hidden at the top: .git, .env, .weft |
weft.toml | layouts/, 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 all | target/ 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
head is where you are: the version your next checkpoint sits under, and the
run a --seed inherits from.
| Command | What it does |
|---|---|
weft tree | The 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 changed | It is a different thing now. weft says so by name |
| Anything downstream of something that changed | Its input is different |
| Anything that never finished: failed, cancelled, still running, or parked on somebody | There is no result to reuse |
| Anything that emitted a live handle, like a bus | The handle belongs to a worker that is gone |
| A whole loop, if any iteration of it is invalid | A loop is reused whole or not at all |
Anything downstream of an explicit --emit or --fire | You asked for a fresh event, so its consumers are fresh |
Anything past --seed-before or --seed-until | You said where to stop |
| A starting value you supplied that differs from the one the earlier run used | Different 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.
| Command | What it does |
|---|---|
weft examples | What 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
| Space | Holds | Gone when |
|---|---|---|
| Execution | What one run made | Five minutes after the run ends, so its output is still downloadable, unless something kept it |
| Project | Things that outlive a run | weft clean or weft rm |
| Shared | Things several projects meet in, by name | You remove them |
| Assets | Your @asset copies | They 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.
Links
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.
| Flag | What 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 |
--displays | Every 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.
| Button | What it does |
|---|---|
| Submit | Answers a question inside a run already going |
| Fire | Starts a new run, on a trigger |
| Skip | Answers with nothing and lets the run carry on. Not shown on a trigger, because skipping a trigger would fire it |
| Cancel run | Ends 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:
- If you have not activated the program since you last changed it, run
weft activateonce: that builds the bridge’s image, which every instance’s copy starts from. weft infra start --instance user-42starts their bridge.- Wait until
weft infra statusshows that copy as running. weft activate --instance user-42turns on itsreceivetrigger.weft token mint --instance user-42 --expires 7d --display bridgemints an instance token that can read that bridge’s display.- 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 source | What the field holds |
|---|---|
a value ("0 0 3 * * *", a picked connection) | that value, for everyone |
| nothing | the node’s default; if it has none, nothing when the field is optional, and a refused run when it is required |
@instance_filled | the 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 source | Whose key an instance’s run uses |
|---|---|
| nothing (your pick on the install) | yours, for every instance |
account: @instance_filled | the 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 aCronis 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 run | Which instance it is for |
|---|---|
weft run --instance user-42 | user-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 header | The token’s instance |
A call to a gated Route with Weft-Instance: user-42 | user-42 |
A timer, a webhook, an open Route, anything else | None |
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
InstanceSettingscomponent, for your own website.weft connect-libcopies the library into your frontend (front/src/lib/weft-connectunless--intonames 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_KEYconnects an account as that instance’s from a key in your environment, and--grant <id>picks one it already has;--listand--disconnectwork the same way;weft instance-values --instance user-42lists its fields and values, and--set read.spreadsheet=1AbC --clear digest.cronchanges 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-Tokenheader; - 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:
| Node | What it does |
|---|---|
StartInstanceInfra | Starts an instance’s copy of an infra node, and fires once it runs |
StopInstanceInfra | Stops it, keeping its disk |
TerminateInstanceInfra | Deletes it and its disk |
InstanceInfraStatus | Reads the state of an instance’s copy |
ListInstanceInfra | Lists every copy of an infra node, the shared one and each instance’s |
ListInstances | Lists 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 |
CurrentInstance | Says which instance the run is for: fires instance with its id, or none |
InstanceCosts | Lists what an instance’s runs cost, with the total |
GetInstanceValues | Reads an instance’s values for the @instance_filled fields, keyed node.field |
SetInstanceValues | Sets or clears an instance’s @instance_filled values, and sets up again any of its triggers that read a changed one |
ActivateInstanceTriggers | Turns on an instance’s triggers |
DeactivateInstanceTriggers | Turns them off |
MintInstanceToken | Mints an instance token to hand to a browser |
WipeInstance | Removes 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 see | What it means |
|---|---|
'bridge' exists once per instance, so this run needs to know which instance it is for | The 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 it | A 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 connection | You sent the header to an open route. Gate the route, or use an instance token |
a bare fire runs for no instance | An instance header reached a webhook’s plain address. Call the route at /connect/... |
this token has expired; ask for a new one | The instance token’s expiry has passed. Mint a new one |
this door takes an instance token | A 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's | Somebody 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 prodsends the program, and a workflow written byweft ci addsends 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:
| Variable | Value |
|---|---|
GCP_PROJECT_ID | $PROJECT |
GCP_REGION | $REGION |
GCP_ZONE | a zone inside it, like us-west1-b |
TF_STATE_BUCKET | $PROJECT-weft-state, the bucket you just made |
GCP_WORKLOAD_IDENTITY_PROVIDER | projects/$NUMBER/locations/global/workloadIdentityPools/weft-install/providers/github (echo $NUMBER prints the number) |
GCP_INSTALL_SERVICE_ACCOUNT | weft-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.
| Piece | What it is |
|---|---|
| Cloud Run | weft’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 holders | a 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 database | everything weft keeps, at the address you gave it |
| Cloud Build | builds your programs’ images when you deploy |
| Pub/Sub | the 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 Tasks | every timer, schedule and poll your programs set, and the wakes weft schedules for itself to check on its own pending work |
| Compute Engine | one machine per infrastructure unit your programs start (a database, a GPU model) |
| A storage bucket | weft’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 Registry | two image repositories: one for the runtime, workers and infra nodes, which only the install writes, and one for frontends |
| Secret Manager | every 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:
| Variable | What it is | Value on your machine |
|---|---|---|
WEFT_DISPATCHER_URL | where the server calls weft: the install’s address, over HTTPS | http://127.0.0.1:14111 |
WEFT_TOKEN | the 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_URL | the start of any link a browser follows | http://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.
weft infra env <node> --on prod --into front/.env.prod --set ...writes a database’s values into it, the same way it does locally.weft infra list-doors --on prodprints 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.weft target export prod --github --front-env front/.env.prodstores the file as theWEFT_FRONT_ENVsecret, 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
401on every command: the key is wrong or was revoked. Runweft login prodagain. In CI, runweft target export prod --githubagain.- 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 poolweft-holderin 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 addressweft domain list --on prodprints; the certificate follows once Google sees it. weft domain listsays 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, anyweft domain addorweft domain rmtries again at once.- weft does not start, and its log says the database session “cannot
LISTEN”:
WEFT_DATABASE_URLgoes through a pooler. Add a direct address of the same database as theWEFT_DATABASE_LISTEN_URLsecret (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
cargooraptline that failed. Fix what it names and deploy again; only what changed rebuilds. - The frontend deploy fails at its
google-github-actions/authstep, or itsgcloud run deployis refusedrun.services.get: the repository is not the one its frontend was added with (weft frontend ls --on prodshows it, spelled as GitHub spells it,owner/name). Remove the frontend and add it again with the right--repo, then runweft target exportagain. - 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 does | What it never does | |
|---|---|---|
| Worker | Runs your compiled program | Touch Postgres: it goes through the broker |
| Dispatcher | Decides what runs, and answers every request about a project or a run | Run your step code |
| Listener | Holds the timers, the open sockets and the subscriptions | Run your step code, or know which project it serves |
| Supervisor | Creates and watches the machines and containers your program asked for | Run 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
SIGTERMis 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 withoutlivesCalleron), the run carries on with nobody there, and the caller hearsthe 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.
| Interface | What it answers |
|---|---|
Runner | How a program’s workers are started and reached |
InfraHost | Where infrastructure units run, and how they are watched |
ImageBuilder | How staged source becomes a runnable image |
Alarm | How a wake set for later is delivered |
CallerIdentity | Who is calling an internal endpoint |
IdentityTokens | How a role proves who it is to another |
FrontendHosting | Where a project’s frontend runs, when the install hosts it |
DomainHosting | What stands in front of the install’s own domains and holds their certificates |
HolderPool | How 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 to | Run |
|---|---|
| Re-run without redoing what did not change | weft run --seed. Changed code and inputs invalidate the affected work and everything downstream of it |
| Exercise one step with values you supply | weft run --from node='{"port":value}' |
| Run up to a step, including it | weft run --target node |
| Run up to a step, excluding it | weft run --before node |
| Run one group on its own | weft run --group group='{"port":value}' |
| Try a trigger without switching the listening on | weft 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:
- 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.
- 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.