Pipedown
Plain text for pipelines
Every workflow tool glues syntax, UI, and runtime into one thing. Change one piece, lose the others.
Markdown solved this for documents — it separated the format from the renderer. Anyone can read it, write it, and any tool can render it. Pipedown does the same thing for pipelines.
One step per line. Newlines are pipes. A step is either a slash command
(/app action args) or plain english. That’s the whole idea.
The pipe is typeless — apps receive a string, return a string, and the LLM
inside each app handles interpretation. --- collects all previous outputs and
the next step reads plain text. No schemas required.
| Traditional workflow tools | Pipedown | |
|---|---|---|
| Step connections | Typed schemas / field mapping | Plain text — --- collects all outputs |
| LLM integration | Optional add-on | First-class step type (any bare line) |
| New integrations | Build connector + define schemas | Write an app, add named actions |
| Readable without tooling | Rarely | Yes — it’s plain text |
This page is the formal spec — types, grammar rules, parse/serialize behavior, and round-trip guarantees. For writing flows as a user, see Writing Flows. For the execution model, see How it Runs.
Program structure
Everything in pipedown compiles to one top-level type. A .pd file parses into
a PipedownProgram:
Canonical order: header, trigger, blocks. The parser is permissive but the serializer emits in this order.
- File extension:
.pd - Language identifier:
pipedown - Code block delimiter in markdown:
```pd - Source of truth for all token patterns:
packages/pipedown/src/schema.ts
Headers
Names and descriptions can be written two ways — both parse to the same fields. The short form is what you’ll write; the legacy forms exist for backwards compatibility with early adopters.
| Syntax | Field | Notes |
|---|---|---|
# Title | name | canonical short form |
# name: Title | name | legacy explicit form |
# description: ... | description | legacy explicit form |
| Plain text lines after title | description | joins with \n until first trigger or step |
## text | description | legacy — ## prefix stripped |
Multiple plain-text lines before the first trigger/step are joined with \n.
The grammar uses negative lookahead on triggerStart and stepStart to stop
consuming description lines once the body begins.
Triggers
Triggers answer one question: when does this flow start? Three options cover the common cases — scheduled work, reacting to events, and on-demand runs.
| Syntax | Type | Notes |
|---|---|---|
every monday morning | schedule | frequency = "monday morning" — AI resolves to cron |
every 15min | schedule | frequency = "15min" |
on stripe | event | app = "stripe", event = "*" |
on stripe.payment | event | app = "stripe", event = "payment" |
| (absent) | manual | implicit when no trigger line |
Grammar patterns:
- Schedule:
/^every\s+(.+)/i - Event:
/^on\s+(\w+)\.(\w+)/i
The manual variant is never serialized (serializer outputs empty string).
Blocks
Blocks are the structural units of a program. They group steps together and control how those steps relate — sequentially, in parallel, or by iteration.
Step block (default)
The simplest form — a bare sequence of step lines with no header:
Variable block
When a step produces output you want to reference later, name it. name = on
its own line owns all lines until the next variable block, ---, or end of
file:
The serializer emits name = then steps without indentation — round-trip does
not preserve indentation of variable block bodies.
Each block
Iteration is how you fan out over variable results. The header is
each REFS:, body must be indented (two spaces required — no non-indented
fallback).
Two parsing modes:
Structured (deterministic): refs use {{variable}} syntax or bare names. Colon is optional.
NLP fallback: anything that doesn’t parse as variable refs is captured as
nlp with refs: []. Ref extraction happens at runtime, not at parse time:
Produces { nlp: "customers from this week", refs: [] }.
- Comma-separated refs:
combineabsent (iterate independently) +-separated refs:combine: true(zip/combine)
Separator
Separators are barriers. Execution waits for all preceding blocks to complete, then combines their outputs into a single context for whatever comes next.
Produces { steps: [], separator: true }.
Steps
Steps are the atoms of pipedown — every line in a flow becomes one. The type covers everything from app calls to conditionals to error handling, keeping the AST uniform.
App step (slash command)
app: word after/action: first word after app (empty string if/appwith no rest)args: remainder after action- Assignment:
= nameat end
LLM step (plain text)
Any line that isn’t a slash command, keyword, or control structure is an LLM step. This is what makes pipedown feel like writing instructions rather than code.
Parsed as app = "llm", action = "do", args = full line text. The parser
converts bare text to LLM steps before the runner sees them — writing
summarize and writing /llm do summarize produce the same parsed step.
Multi-line LLM steps use indented continuation lines. Indented lines following an LLM step are joined to the step’s args:
This parses as a single LLM step with args spanning all four lines.
Conditional step
Branching uses natural language or expressions. The parser detects both forms and routes them differently at runtime — natural language gets evaluated by an LLM, backtick expressions run as code.
- Natural language:
app = "if",action = "check",args = condition - Expression (backtick):
app = "if",action = "eval",args = expression
Conditionals support an indented body. Steps indented under if, else if, or
else only run when that branch matches:
The indented steps are stored in the body field of the conditional step.
Else-if step
- Natural language:
app = "else-if",action = "check",args = condition - Expression (backtick):
app = "else-if",action = "eval",args = expression
Else step
app = "else",action = "",args = ""
Only the first matching branch in an if/else-if/else chain executes. A
--- separator or a new if resets the chain.
Cancel step
Sometimes a flow should bail entirely. cancel checks a condition and stops
execution if it’s met — useful for quota checks, guard clauses, or sanity
checks before expensive operations.
- Natural language:
app = "cancel",action = "check" - Expression:
app = "cancel",action = "eval"
Error handling
Errors are inevitable. These modifiers control what happens when a step fails, without requiring try/catch ceremony.
! retry N:app = "error",action = "retry",args = "3"! ignore:app = "error",action = "ignore",args = ""! fallback:app = "error",action = "fallback",args = ""
! fallback is a plain step with no body field. Indented steps shown after
! fallback in user docs are regular subsequent steps — the execution layer
interprets them as the fallback body by position, not by AST nesting.
Wait step
Flows that need human approval or time delays use /wait. It gets its own
grammar rule that fires before slashStep, giving it special-case parsing.
@persononly:app = "wait",action = "approval"@person duration:app = "wait",action = "approval"- Duration only:
app = "wait",action = "duration"
Parallelism
Two levels:
- Step-level:
*prefix on a step setsstep.parallel = true - Block-level:
* each ...prefix on an each block setsblock.parallel = true
- prefix is sequential sugar (equivalent to bare line). The - return value
from the grammar PREFIX rule is discarded — only * sets parallel. The -
prefix leaves no AST trace.
Variables
Variables are how steps share data. Three roles:
- Assignment:
= varnameat end of step setsstep.assign = "varname" - Reference:
{{varname}}in args text references another step’s output - Property access:
{{varname.prop}}accesses a property on assigned output
Variable names: [\w-]+ (alphanumeric plus hyphens).
Variable blocks (name = on its own line) automatically assign the block’s
final output to that variable name.
@mentions in step text (e.g. @oncall, @team) are highlighted in the
editor but are not variable references — they are passed through as plain text
for apps to interpret.
Parse/serialize round-trip
A key property of pipedown: structural idempotence. You can parse, serialize, and re-parse without losing meaning — even if some cosmetic details change.
Preserved (structural idempotence):
- All semantic content: name, description, trigger, block structure, step content, variables, parallel flags
rawfield preserves the original verbatim sourceparse(serialize(parse(text)))equalsparse(text)for all fields
Not preserved (known lossy points):
- Blank lines between blocks (serializer emits one blank line before each block)
- Indentation of variable block bodies (serializer emits non-indented)
-sequential prefix (stripped, not stored in AST)# name:/# description:legacy header forms (serializer emits# Title)## descriptionlegacy form (serializer emits plain description text)
Public API
The pipedown package exposes a small, focused surface. Everything lives in
packages/pipedown/src/:
parse() has a silent catch-all: on peggy parse failure it returns
{ blocks: [], raw: content }. Tooling should treat an empty blocks array
with a non-empty raw as a potential parse failure.
schema.ts is authoritative — all token patterns and descriptions live there.
Grammar.ts derives from it. Tooling should import from schema.ts, not hardcode
patterns.
PEG grammar notes
The grammar is the heart of pipedown’s parser. It lives at
packages/pipedown/src/pipedown.peggy and is compiled to
pipedown-parser.generated.js at build time. These notes are for contributors
working on the grammar itself.
Key design points:
programHeadis optional — programs without headers parse finedescriptionLineuses negative lookahead ontriggerStartandstepStart- Variable blocks use
name =syntax; own all lines until the next variable block,---, or EOF eachBlockrequires indented body (two-space indent); no non-indented fallbackeachBlocktries structured variable refs first; falls back to NLP string with extracted refseachBlockparallel prefix (* each) setsblock.parallel, notstep.parallelanyStepLineguards against consuming separator/each/variable block startswaitStepfires beforeslashStep, giving/waitspecial-case parsingLINEmatches[^\n\r]+— never crosses newlines; all multi-line structure is block-level- The grammar requires a trailing newline;
parse()adds one if absent else ifparses toapp: 'else-if';elseparses toapp: 'else'- Conditional steps (
if/else if/else) can have an indented body of sub-steps that only execute when the branch matches
Grammar to AST mapping
This table shows how every grammar rule maps to an AST node — useful when
reading the .peggy source or debugging parse output.
| Grammar rule | AST node | Key fields |
|---|---|---|
program | PipedownProgram | name, description, trigger, blocks |
titleEntry | — | sets PipedownProgram.name |
descriptionLine | — | sets PipedownProgram.description |
triggerEntry | PipedownTrigger | type, frequency or app/event |
separator | PipedownBlock | separator: true, steps: [] |
variableBlock | PipedownBlock | label, steps |
eachBlock | PipedownBlock | each, steps, parallel? |
stepsBlock | PipedownBlock | steps |
slashStep | PipedownStep | app, action, args, assign? |
llmStep | PipedownStep | app: 'llm', action: 'do', args, assign? |
conditionalStep | PipedownStep | app: 'if', action: 'check'/'eval', body? |
elseIfStep | PipedownStep | app: 'else-if', action: 'check'/'eval', body? |
elseStep | PipedownStep | app: 'else', action: '', body? |
cancelStep | PipedownStep | app: 'cancel', action: 'check'/'eval' |
errorStep | PipedownStep | app: 'error', action varies |
waitStep | PipedownStep | app: 'wait', action varies |
For CLI tools, editor support, and syntax highlighting, see Tooling.