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 toolsPipedown
Step connectionsTyped schemas / field mappingPlain text — --- collects all outputs
LLM integrationOptional add-onFirst-class step type (any bare line)
New integrationsBuild connector + define schemasWrite an app, add named actions
Readable without toolingRarelyYes — 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:

type PipedownProgram = {
name?: string
description?: string
trigger?: PipedownTrigger
blocks: PipedownBlock[]
raw: string // original source, preserved verbatim
}

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.

SyntaxFieldNotes
# Titlenamecanonical short form
# name: Titlenamelegacy explicit form
# description: ...descriptionlegacy explicit form
Plain text lines after titledescriptionjoins with \n until first trigger or step
## textdescriptionlegacy — ## 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.

type PipedownTrigger =
| { type: 'schedule'; frequency: string }
| { type: 'event'; app: string; event: string }
| { type: 'manual' }
SyntaxTypeNotes
every monday morningschedulefrequency = "monday morning" — AI resolves to cron
every 15minschedulefrequency = "15min"
on stripeeventapp = "stripe", event = "*"
on stripe.paymenteventapp = "stripe", event = "payment"
(absent)manualimplicit 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.

type PipedownBlock = {
label?: string
steps: PipedownStep[]
parallel?: boolean
each?: PipedownEach
separator?: boolean
}

Step block (default)

The simplest form — a bare sequence of step lines with no header:

/stripe list-customers churned in last 30 days summarize the results

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:

week = /analytics get weekly-summary summarize trends

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

type PipedownEach = {
refs: string[]
combine?: boolean
/** present when expression couldn't be parsed deterministically -- runtime resolves */
nlp?: string
}

Two parsing modes:

Structured (deterministic): refs use {{variable}} syntax or bare names. Colon is optional.

each churned /email draft re-engagement message each {{week}}, {{month}} compare trends each {{week}} + {{month}} highlight differences

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:

each of my aunts toys in {{box}} that she loves the most! /slack notify

Produces { nlp: "customers from this week", refs: [] }.

  • Comma-separated refs: combine absent (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.

type PipedownStep = {
app: string
action: string
args: string
parallel?: boolean
assign?: string
errorMode?: 'must' | 'optional'
body?: PipedownStep[]
}

App step (slash command)

/app action natural language args = varname /app action = varname /app
  • app: word after /
  • action: first word after app (empty string if /app with no rest)
  • args: remainder after action
  • Assignment: = name at 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.

summarize the top findings = summary

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:

write a report covering: revenue trends user growth churn analysis

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.

if message mentions deploy if `input.includes("deploy")`
  • 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:

if there are critical issues /notify alert @oncall escalate to management else if there are warnings /notify alert @team else summarize that everything looks good

The indented steps are stored in the body field of the conditional step.

Else-if step

else if there are warnings else if `count > 10`
  • Natural language: app = "else-if", action = "check", args = condition
  • Expression (backtick): app = "else-if", action = "eval", args = expression

Else step

else
  • 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.

cancel if usage exceeds limit cancel if `quota.remaining <= 0`
  • 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 3 ! ignore ! fallback
  • ! 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.

/wait @alice /wait @alice 24h /wait 2h
  • @person only: app = "wait", action = "approval"
  • @person duration: app = "wait", action = "approval"
  • Duration only: app = "wait", action = "duration"

Parallelism

Two levels:

  1. Step-level: * prefix on a step sets step.parallel = true
  2. Block-level: * each ... prefix on an each block sets block.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:

  1. Assignment: = varname at end of step sets step.assign = "varname"
  2. Reference: {{varname}} in args text references another step’s output
  3. 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
  • raw field preserves the original verbatim source
  • parse(serialize(parse(text))) equals parse(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)
  • ## description legacy form (serializer emits plain description text)

Public API

The pipedown package exposes a small, focused surface. Everything lives in packages/pipedown/src/:

// parse.ts
parse(content: string): PipedownProgram
parseBlocks(content: string): PipedownBlock[]
parseStep(text: string): PipedownStep
extractFromCodeBlock(text: string): string | null
// serialize.ts
serialize(program: PipedownProgram): string
serializeStep(step: PipedownStep, indent?: string): string
// schema.ts
schema // all token patterns and descriptions
// types
type PipedownProgram
type PipedownBlock
type PipedownStep
type PipedownTrigger
type PipedownEach
// grammar.ts
CODE_BLOCK_START, CODE_BLOCK_END
SLASH, PARALLEL_PREFIX, SEQUENTIAL_PREFIX, SEPARATOR
VARIABLE_PATTERN, VARIABLE_BLOCK_PATTERN, EACH_PATTERN
CONDITIONAL_PATTERN, TRIGGER_SCHEDULE, TRIGGER_EVENT
HEADER_NAME, HEADER_DESCRIPTION, HEADER_NAME_SHORT

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:

  • programHead is optional — programs without headers parse fine
  • descriptionLine uses negative lookahead on triggerStart and stepStart
  • Variable blocks use name = syntax; own all lines until the next variable block, ---, or EOF
  • eachBlock requires indented body (two-space indent); no non-indented fallback
  • eachBlock tries structured variable refs first; falls back to NLP string with extracted refs
  • eachBlock parallel prefix (* each) sets block.parallel, not step.parallel
  • anyStepLine guards against consuming separator/each/variable block starts
  • waitStep fires before slashStep, giving /wait special-case parsing
  • LINE matches [^\n\r]+ — never crosses newlines; all multi-line structure is block-level
  • The grammar requires a trailing newline; parse() adds one if absent
  • else if parses to app: 'else-if'; else parses to app: '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 ruleAST nodeKey fields
programPipedownProgramname, description, trigger, blocks
titleEntrysets PipedownProgram.name
descriptionLinesets PipedownProgram.description
triggerEntryPipedownTriggertype, frequency or app/event
separatorPipedownBlockseparator: true, steps: []
variableBlockPipedownBlocklabel, steps
eachBlockPipedownBlockeach, steps, parallel?
stepsBlockPipedownBlocksteps
slashStepPipedownStepapp, action, args, assign?
llmStepPipedownStepapp: 'llm', action: 'do', args, assign?
conditionalStepPipedownStepapp: 'if', action: 'check'/'eval', body?
elseIfStepPipedownStepapp: 'else-if', action: 'check'/'eval', body?
elseStepPipedownStepapp: 'else', action: '', body?
cancelStepPipedownStepapp: 'cancel', action: 'check'/'eval'
errorStepPipedownStepapp: 'error', action varies
waitStepPipedownStepapp: 'wait', action varies

For CLI tools, editor support, and syntax highlighting, see Tooling.