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) and Js.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)