You write a step once in TypeScript, declare what it needs, and the same file runs on a laptop, on a small server, or in an edge isolate. Nothing about the step changes between those, and no build flag selects between them.
Waybill is a library and a file layout. It is not a platform, it has no server, no UI, and no scheduler. Whatever already decides when your jobs run keeps deciding.
Reach for it when a job needs to remember what it has already seen, retry without doing the work twice, and leave a record of what it did. Those three needs are what the state capabilities exist for, and they are what a cron one-liner starts failing at.
Reach for something else when the job is a one-shot script, when it needs long or heavy compute, when it
has to sleep for days waiting on a human, or when you want a product with a UI and credential management.
docs/ARCHITECTURE.md names the neighbours it is deliberately not, and the ancestors it comes from.
flowchart LR
in(["{ url }"]) --> A1["A_EXTRACT_CONTENT<br/><i>requires: fetch</i>"]
A1 -->|"+ content"| A2["A_LABEL_AND_RATE<br/><i>requires: llm</i>"]
A2 -->|"+ labels, score"| A3["A_ROUTE<br/><i>requires: nothing</i>"]
A3 -->|"+ destination"| out(["{ url, content, labels, score, destination }"])
One action per box, and what each one adds on the arrow leaving it. The chain is a pipeline; a flow is what puts items into a chain on a schedule and stores what comes out.
| Primitive | Is | State | Handles |
|---|---|---|---|
| Action | one unit of work: fetch, parse, an LLM call, a routing decision | none | one envelope |
| Pipeline | an ordered chain of actions | none | one envelope |
| Flow | a producer (schedule to queue) or a consumer (queue to pipeline to store) | kv, queue, db |
many |
The two arrows differ. Action to pipeline is pure composition. Pipeline to flow adds state and cardinality, which is why a flow is the only place a store appears.
The envelope accumulates. Every action destructures what it needs and returns the rest alongside its own output:
const { content, ...upstream } = input;
return { ...upstream, summary, labels };The last action in a chain therefore sees every field every earlier action added. This is the invariant that makes chains composable, and it is why provenance is a property of the data rather than something logged next to it.
Capabilities are injected, never imported. An action declares what it needs in its manifest:
{ "name": "A_EXTRACT_ARTICLE", "requires": ["fetch"] }The runner hands it a fetch, and an action that declared nothing is handed nothing. The declaration
is doing two jobs at once: it is what the runner will give the step, and it is the placement
constraint. shell cannot be satisfied by an edge isolate, so a unit that asks for it is not deployable
there, and the checker says so before a deploy rather than after.
This is a declaration, not a sandbox. It constrains what the runner hands an action, not what an
action's own module can import, so an action you add runs with your privileges and you should treat
adding one exactly like adding a dependency. SECURITY.md says where that line falls.
flowchart TD
M["<b>requires</b> in the manifest"] --> C{"waybill check"}
C -->|"nothing, fetch, llm"| E["laptop · server · edge"]
C -->|"shell"| S["laptop · server"]
C -->|"readFile"| D["wherever the files are"]
The same declaration answers three questions before anything runs: what the step may touch, where it may be deployed, and what a reviewer has to read to know both.
Bun 1.2 or newer, and nothing else. Clone, then:
bun bin/waybill list
bun bin/waybill run --local A_ROUTE --input '{"quality_score":92,"labels":["ai"]}'
bun bin/waybill checkAll three run in a fresh clone with no configuration and no credential: A_ROUTE is a pure decision over
its input, and list and check only read manifests. check prints what each unit needs and where it
may run, which is the command worth reading the output of first.
To type waybill rather than bun bin/waybill, run bun link once in the repo.
Steps that declare llm are the ones that need a provider. Copy .env.example to .env and set
ANTHROPIC_API_KEY, or point WAYBILL_INFERENCE_PATH at a local command that answers the same
string-in, string-out contract.
Actions/ A_*/action.json + action.ts one unit of work each
Pipelines/ P_*/pipeline.json an ordered list of action names
Flows/ F_*/flow.json + flow.ts producer or consumer, the only stateful layer
Workers/ one directory per deployed edge worker
lib/ the runner, the types, the checker
shared/ capability implementations and the local store
bin/waybill the CLI
The repository ships the pipeline it was built for, a feed: poll sources, drop what has been seen, fetch and summarise and rate each new item, route it, store it. It is worth reading because it exercises every part of the model at once. A producer flow enqueues, a consumer flow drains, a pipeline does the per-item work, and dedup happens on both sides because a queue that delivers at least once will eventually deliver twice.
The Cloudflare configuration under Workers/ is real and deployable, with account-specific values
replaced by YOUR_* placeholders. Substitute your own resources before deploying.
docs/ARCHITECTURE.md— why it is shaped this way. The four decisions, what a capability maps onto per substrate, the lineage, and where the model stops.docs/BUILDING_AUTOMATIONS.md— how to build a new automation on it. Start here if you want to write something rather than understand something.docs/USE-CASES.md— what the primitives reach, pattern by pattern, each marked as running, buildable, or needing a named extension.CONTRIBUTING.md— the gates a change has to pass, what0.xmeans here, and how a release is cut.SECURITY.md— what the capability declaration protects, what it does not, and how to report something privately.
- No branching. A pipeline is a linear list. No conditionals, no fan-out, no join.
- No durable waiting. Nothing survives a process death mid-run and resumes days later.
- No rollback. A failed step leaves earlier side effects in place.
- No
shellin the cloud, and no long or heavy compute at the edge, where CPU and time are capped.
Failures are data rather than exceptions: a step that fails returns a result saying so, with the state of the world at that moment intact, and the caller decides whether that is a retry or a dead letter.
0.x, which here means what SemVer says it means: the interfaces still move, and a minor bump may
break them. Every move that matters is in CHANGELOG.md, and each release on the releases page
carries that file's section for it, extracted rather than retyped. bun bin/waybill --version reports
what you have. MIT licensed.