plumbing - plumbing language file format
DESCRIPTION
A .plumb file defines types, processes, and pipelines in the plumbing language — a typed composition algebra for agent networks.
Processes are Unix processes; channels are Unix pipes. Types are validated at channel boundaries at runtime.
SYNTAX
Comments
OCaml-style nested comments:
(* This is a comment (* and so is this *) *)
Type declarations
type Name = type_expr
Primitive types: string, int, number, bool, unit.
Record types with strict validation (extra fields rejected):
type Query = { text: string, max_results?: int }
Optional fields are marked with ? and may be absent from the JSON value.
Array types:
type Messages = [string]
Sum types (variants must be pairwise disjoint):
type Result = { text: string } | { error: string }
Product types (fixed-length heterogeneous tuples):
type Pair = (string, int)
Stream types (binding-level only):
let f : !A -> !B = ...
The ! prefix denotes a stream of values. Stream types appear in binding signatures, not in data-level type declarations.
Type aliases:
type Text = string
Process bindings
let name : input_type -> output_type = impl
Agent
An LLM conversation loop:
let composer : !string -> !string = agent {
model: "claude-sonnet-4-5-20250514"
prompt: "You are a helpful assistant."
max_tokens: 8192
}
See plumb-agent(1) for the full list of configuration keys.
Identity
let echo : !Message -> !Message = id
Copy (fan-out)
let fan : !Message -> !Message = copy
Duplicates each message to two output channels.
Merge (fan-in)
let join : !Message -> !Message = merge
Interleaves messages from two input channels.
Discard
let sink : !Message -> !Unit = discard
Map
let extract : !Record -> !Field = map(field_name)
let double : !int -> !int = map(value * 2)
Filter
let good : !Verdict -> !Verdict = filter(score >= 85)
Messages that fail type validation against the filter's declared type are silently dropped (typed selector behaviour). JSON parse errors and expression evaluation errors remain fatal.
Barrier
let sync : !(Message, Message) -> !(Message, Message) = barrier
Synchronised fan-in. Ports: in0, in1, output.
Project
let first : !(Message, Message) -> !Message = project(0)
let second : !(Message, Message) -> !Message = project(1)
Product projection. Extracts the n-th component from a product stream. Zero-based index, checked at load time.
Tool
Lower a stream process to a single-shot morphism:
let search : Query -> Results = tool { process: searcher }
Or annotate a bare-typed binding:
@tool true
@description "Double the score"
let double : int -> int = map(score * 2)
Annotations
@key value
Annotations precede bindings. The @tool annotation marks a bare-typed binding as a tool. The @description annotation provides a description for LLM tool schemas.
Protocol declarations
protocol Name = send MsgType . recv MsgType . end
Declares a session type — the ordering constraints on messages over a channel. send T outputs a value of type T; recv T receives one. . sequences steps; loop repeats from the top of the protocol; end terminates the session.
Labelled choice (the initiator selects a branch):
protocol Ops = {
read: send Path . recv Content . loop,
write: send (Path, Content) . recv Ack . loop,
close: end
}
The dual of a protocol (swap send/recv, select/offer) gives the type seen by the process at the other end.
Protocol names are qualified by module: Module.Protocol.
Protocol declarations are checked for well-formedness (guardedness, message type validity, label uniqueness) but are not yet compiled to runtime behaviour. The dual of a protocol (swap send/recv, select/offer) gives the type seen by the process at the other end. Well-formedness requires all labels in a choice to be unique and all recursion to be guarded by at least one send or recv.
Modules
module Name
Declares a module namespace. All subsequent bindings are scoped under Name and accessed as Name.binding.
Imports
Two forms:
use fs
Bare identifier — appends .plumb and resolves via the search path. Checks the importing file's directory first (local shadow), then PLUMB_PATH directories, then the installed standard library.
use "path/to/file.plumb"
Quoted string — resolves relative to the importing file only. No search path, no extension inference.
Pipeline bodies
A plumb binding defines a pipeline:
let main : !A -> !B = plumb(input, output) {
...
}
Static form (wiring statements)
Semicolon-separated composition chains:
input ; composer ; checker
checker ; filter(substantiated = true).draft ; output
checker ; filter(substantiated = false).commentary ; composer
Processes appearing as source in multiple chains get automatic fan-out (copy). Processes appearing as target in multiple chains get automatic fan-in (merge).
Inline operations in chains:
- filter(expr) — predicate filter
- .field — field projection (equivalent to map(field))
Dynamic form (explicit channels and spawns)
let ch : !Message = channel
spawn process(ch_in, ch_out)
Named port arguments:
spawn process(input=ch_in, output=ch_out)
Tensor product
Parallel composition with * (tensor product):
(input ; f * input ; g)
Runs f and g on independent channels.
Port addressing
process@port
Addresses a specific port of a multi-port process.
Expressions
Map and filter use an expression sublanguage. Bare identifiers are field names on the current message:
filter(score >= 85)
map({ doubled: score * 2, pass: score >= 85 })
Supported operators: field access (nested with .), arithmetic (+, -, *), comparison (=, !=, <, >, <=, >=), boolean (&&, ||, not), unary minus, record construction, string and numeric literals, parentheses.
TYPE SYSTEM
| Plumbing type | JSON | Validation |
|---|---|---|
| string | string | exact match |
| int | integer | accepts float if integer-valued |
| number | number | accepts int, coerces to float |
| bool | boolean | exact match |
| json | any | accepts any valid JSON value |
| Unit | null | exact match |
| { f: T } | object | strict — extra fields rejected |
| [T] | array | each element validated |
| (A, B) | array | fixed-length, positional |
| T? | optional | field may be absent |
| A | B | any | first matching disjoint variant |
FILES
Standard library modules
Standard library modules are installed alongside the binaries. The search path for use declarations is assembled from PLUMB_PATH and the installed library directories.
- stdlib/fs.plumb
- Filesystem tool types and bindings (read, write, search, list).
- stdlib/sys.plumb
- System tool types and bindings (exec, check, call).
- stdlib/js.plumb
- JSON conversion:
Js.format(!json → !string) andJs.parse(!string → !json).
Resource files
Resource files (system prompts, grammar) are installed under share/plumbing/resources/. The search path for bare prompt file names in agent prompts arrays is assembled from PLUMB_RESOURCES and the installed resource directories.
Prompt file paths are resolved as follows:
- Bare name (no /) — searched via the resource path. Example: "system.md", "grammar.peg".
- Path (contains /) — resolved relative to the .plumb file that defines the agent. Absolute paths used as-is. Example: "./composer.md", "./heat.plumb".
- resources/system.md
- System prompt explaining plumb agent contracts and pipeline topology.
- resources/grammar.peg
- PEG grammar of the plumb language, included as reference for agents.
SEE ALSO
plumb(1), plumb-chat(1), plumb-agent(1)