Every building block you compose inside a @PipelineBuilder body, with real signatures and
examples. All examples assume import ComposablePipelines.
- The
Pipelineprotocol @Stateand bindingsModelGuardrailRun(client tasks)WhileForEachGroup(andConcurrent/Barrier)SummarizeJust- Native control flow
public protocol Pipeline {
associatedtype Body: Pipeline
associatedtype Output = Body.Output
@PipelineBuilder var body: Self.Body { get }
}Conform a struct, pin Output, and compose steps in body:
struct Greeting: Pipeline {
typealias Output = String
let name: String
var body: some Pipeline {
Model<String, String>(
instructions: "Greet the user by name.",
input: Just(value: name)
)
}
}The Output of a builder block is the last step's output. Leaf steps such as Model and
Run pin their own Output via generics.
Steps do not receive each other's typed results as inputs — dataflow goes through
@State.
Shared, typed context for a run. Each @State is a slot identified by a UUID; values flow
through the graph as JSON. The value type must be Hashable & Sendable & Codable.
@propertyWrapper
public struct State<Value: Hashable & Sendable & Codable> {
public init(wrappedValue: Value, _ debugLabel: String? = nil)
public var wrappedValue: Value
public var projectedValue: Binding<Value> // the `$state` accessor
}Two ways to touch a slot:
- Bare
state— reads the slot's value as of the current epoch while the body is being (re-)evaluated. Use it in Swiftif/switchand string interpolation. $state(the binding) — graph-level operations the walker runs:
public struct Binding<Value> {
public func set<P: Pipeline>(_ pipeline: P, writeKind: StateWriteKind = .commit) -> … where P.Output == Value
public func set<P: Pipeline>(writeKind: StateWriteKind = .commit, @PipelineBuilder _ pipeline: () -> P) -> … where P.Output == Value
public func set(_ value: Value, writeKind: StateWriteKind = .commit) -> …
public func get() -> GetValue // graph-level slot read
}struct DocumentSummary: Pipeline {
typealias Output = String
let document: String
@State var keyPoints = ""
@State var draft = ""
var body: some Pipeline {
$keyPoints.set { // write slot from a step
Model<String, String>(
instructions: "Extract the 5 most important points.",
input: Just(value: document)
)
}
$draft.set { // read previous slot
Model<String, String>(
instructions: "Write a concise summary from these points.",
input: $keyPoints.get()
)
}
$draft.get() // return slot value
}
}Write kinds. .commit (default) participates in incremental re-execution — after the
write, the body re-evaluates and the compiler can produce a new graph. .draft updates the
runner slot without triggering re-evaluation (useful for streaming partials).
.assign(to:) vs .set { }. producer.assign(to: $slot) is the producer-first spelling of
the value form $slot.set(producer). It's the right tool when the producer's dependencies are
graph-encoded — .input { $other }, Run, $x.map { … }, constants, or plain lets.
It cannot capture a bare @State read made while building the producer (e.g.
Model("…").message(keyPoints) or a prompt interpolating \(severity)), because a postfix
modifier can't snapshot reads before the producer was constructed. For those, use the closure
form $slot.set { producer }, which snapshots reads up front and records the dependency:
// graph-encoded dependency → .assign is fine
Model<String>("Summarize.").input { $keyPoints }.assign(to: $draft)
// producer bakes a bare @State read → use the closure form so the dependency is captured
$draft.set { Model<String>("Summarize \(topicTone) style.").message(keyPoints) }
@Statedefaults are baked into the compiled graph, so you don't have to seed them throughinitialSlotswhen running.
A model inference step, generic over <Input, Output>. The walker hands the resolved
instructions/tools/input to your Executor.
Common initializers:
// instructions + input, both as constants or pipelines
public init<Instr: PipelineConvertible, InP: PipelineConvertible>(
requirements: ModelSelectionRequirements? = nil,
instructions: Instr,
input: InP
) where InP.Output == Input
// input as a @PipelineBuilder closure (e.g. reading a slot)
public init<Instr: PipelineConvertible, InP: Pipeline>(
requirements: ModelSelectionRequirements? = nil,
instructions: Instr,
@PipelineBuilder input: @escaping () -> InP
) where InP.Output == Input
// with tools
public init<Instr: PipelineConvertible, InP: PipelineConvertible>(
requirements: ModelSelectionRequirements? = nil,
instructions: Instr,
tools: [any PipelineTool],
input: InP
) where InP.Output == Input
// instructions only (Output == String, no input)
public init(requirements: ModelSelectionRequirements? = nil, instructions: String)Model<String, String>(
instructions: "Classify sentiment as positive, neutral, or negative.",
input: $review.get()
)requirements is a soft intent the runtime resolves to a concrete model — not a model ID.
Selection happens in your executor, never in the authoring layer.
public struct ModelSelectionRequirements {
public init(
purpose: ModelPurpose? = nil, // .chatCompletion, .textGeneration, .summarize, …
backend: ModelBackend? = nil, // .mlx or .custom("…")
traits: ModelSelectionTraits = [], // .quick, .reasoning, .localOnly, .lowMemory, …
spec: ModelSpecDescriptor? = nil // optional pin: sourceID / revision / files
)
}let fast = ModelSelectionRequirements(purpose: .textGeneration, traits: [.quick, .lowCost])
Model<String, String>(
requirements: fast,
instructions: "Summarize in one sentence.",
input: $text.get()
)ModelSelectionTraits is an OptionSet: .quick, .localOnly, .instruct, .reasoning,
.streaming, .lowMemory, .lowCost.
Evaluates content-safety rules. By default it gates — the run does not proceed past it
unless the executor's runGuardrail returns true.
public init(rules: [GuardrailRule], gated: Bool = true)
public enum GuardrailRule: String { case politics, pii }var body: some Pipeline {
Guardrail(rules: [.politics, .pii])
Model<String, String>(instructions: "Reply.", input: $message.get())
}Rules read as a variadic list on GuardrailClassification (the Bool-producing classifier)
and on GateGuardrail (a bare gate over one input), so you skip the rules: label and the
array brackets:
GuardrailClassification(.politics, .pii) { $message.get() } // -> Bool
GateGuardrail(.politics, .pii) { $message.get() } // gate; proceeds only if it passesRuns a closure on the host/client — the boundary for outside-world access (files, network,
secrets, UI). The graph records that a task runs (by taskID); the host decides how.
ClientTask is the previous name and remains available as a typealias.
// one binding in — Input from the binding, Output from the closure
public init(id: String? = nil, _ input: Binding<Input>,
action: @escaping @Sendable (Input) async throws -> Output)
// two / three bindings in — the input lowers to a `combine` leaf and arrives together
public init<A, B>(id: String? = nil, _ first: Binding<A>, _ second: Binding<B>,
action: @escaping @Sendable (A, B) async throws -> Output)
where Input == Combined2<A, B>
// no input
public init(taskID: UUID = UUID(),
action: @escaping @Sendable () async throws -> Output) where Input == Never
public init(id: String,
action: @escaping @Sendable () async throws -> Output) where Input == Never
// input pipeline + async action (general form)
public init<InputPipeline: Pipeline>(
taskID: UUID = UUID(),
@PipelineBuilder input: @Sendable @escaping () -> InputPipeline,
action: @escaping @Sendable (Input) async throws -> Output
) where InputPipeline.Output == InputRun($draft) { text in
text.split(whereSeparator: \.isWhitespace).count
}
.assign(to: $wordCount)
Run($draft, $tone) { draft, tone in applyTone(tone, to: draft) }
.assign(to: $draft)
Run { try await fetchUserLocale() }
.assign(to: $locale)Pure single-slot transforms read better as Binding.map — it lowers to the same
clientAction leaf:
$draft.map { $0.count }.assign(to: $wordCount)
$draft.map(\.wordCount).assign(to: $summaryLength)Identity. Closure captures never cross the wire — only the taskID does. The default
taskID is a fresh UUID per value, which is fine for local runs (the lowering registers the
closure each time). When a graph is encoded and executed remotely, give the task a stable
string id:; both sides derive the same UUID via UUID(stableTaskName:), so the host's
clientActionProvider can dispatch it across lowerings and launches.
When you run a graph that contains client tasks, supply a clientActionProvider to
PipelineWalker.run so the walker can dispatch each taskID (see Executors).
Repeats its body while a Swift predicate holds. The condition is re-evaluated after each
committed state write — so the loop terminates through @State, not a hidden counter.
public init(condition: @Sendable @escaping () -> Bool,
@PipelineBuilder body: @Sendable @escaping () -> Body)
// autoclosure spelling — the condition expression is re-evaluated on every emission
public init(_ condition: @autoclosure @Sendable @escaping () -> Bool,
@PipelineBuilder body: @Sendable @escaping () -> Body)@State var reply = ""
var body: some Pipeline {
While(reply.isEmpty) { // autoclosure; same as condition: { reply.isEmpty }
$conversation.map { raw in
raw.trimmingCharacters(in: .whitespacesAndNewlines)
}
.assign(to: $reply)
}
$reply // bare binding == $reply.get()
}The walker caps re-execution depth at PipelineWalker(maxReexecutionDepth:) (default 200).
Lowers a collection into a sequence of body emissions — one per element, in order.
public init(in data: @escaping @Sendable () -> C,
@PipelineBuilder content: @escaping @Sendable (C.Element) -> Content)
public init(in data: C,
@PipelineBuilder content: @escaping @Sendable (C.Element) -> Content)
// SwiftUI-parity spellings — drop the `in:` label
public init(_ data: C, @PipelineBuilder content: @escaping @Sendable (C.Element) -> Content)
public init(_ data: @escaping @Sendable () -> C,
@PipelineBuilder content: @escaping @Sendable (C.Element) -> Content)ForEach(chunks) { chunk in // no `in:` label
Run { noteKeyFact(in: chunk) }.assign(to: $notes)
}An empty collection lowers to nothing. Pass values between iterations through shared @State
rather than relying on a typed element result.
Bundles steps into a unit the compiler treats atomically.
public init(sequential: Bool = true, gate: Bool = false,
@PipelineBuilder _ content: () -> Content)sequential(defaulttrue): the group runs as one block; no internal parallelization.gate(defaultfalse): everything after the group waits for it to finish, regardless of slot dependencies — a barrier.
For the two non-default combinations, prefer the named spellings — they read at the call site
where Group(sequential:gate:)'s booleans don't:
Concurrent { … } // == Group(sequential: false) — steps may run in parallel
Barrier { … } // == Group(gate: true) — a barrier; downstream waits
Group { … }.gated() // == Group(gate: true) — modifier form
Barrier {
GateGuardrail(.politics, .pii) { $message.get() } // nothing downstream starts until this clears
}Condenses the text in a slot to a token budget.
public init(text: Binding<String>, maxTokens: Int)$summary.set {
Summarize(text: $draft, maxTokens: 512)
}Lifts a constant value into a pipeline — most often as a Model/Run input.
public init(value: Output)Model<String, String>(instructions: "Echo.", input: Just(value: "hello"))@PipelineBuilder supports Swift if/else, if without else, and #available. Branch
conditions read state via the bare state accessor; on a state change the body re-evaluates
and the compiler emits an updated graph.
struct Moderate: Pipeline {
typealias Output = String
@State var input: String
@State var severity = ""
@State var explanation = ""
var body: some Pipeline {
Guardrail(rules: [.politics, .pii])
$severity.set {
Model<String, String>(
instructions: "Classify severity as safe, medium, or high.",
input: $input.get()
)
}
if severity == "safe" {
$explanation.set("Content is safe. No action needed.")
} else {
$explanation.set {
Model<String, String>(
instructions: "Write a brief moderation explanation for \(severity).",
input: $input.get()
)
}
}
$explanation.get()
}
}Each branch must itself be a Pipeline. See Architecture for how
re-evaluation and prefix-skip keep this from re-running completed work.