Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Files and reuse

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

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

@include

triage = @include("triage.weft")

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

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

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

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

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

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

@file

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

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

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

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

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

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

@asset

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

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

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

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

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

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

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

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

The source can also be:

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

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

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

The asset sync

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

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

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

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

What lands in source

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

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

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

The project’s own nodes

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

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

The whole folder

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

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

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

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

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