Writing Flows

A language that gets out of the way

Most workflow tools make you think in their language — dragging boxes, wiring JSON, configuring YAML. Pipedown goes the other direction:

Write what you mean, one step per line.
The syntax disappears so you can focus on the pipeline.

Think of it like Markdown for automation. Markdown took the most common text formatting patterns and gave them the thinnest possible syntax — just enough structure, then it gets out of the way. Pipedown does the same thing for pipelines: a handful of symbols on top of plain language, and that’s it.

This page is the complete syntax reference. Everything you can write in a flow is here. But rather than just listing features, we’ll walk through them the way you’d actually learn them — starting simple, building up, and showing why each piece exists.

Naming your flow

Every flow starts with a name and description. The # Title is what shows up in the UI, and the plain text after it explains what the flow does:

# Daily Standup Summarizes yesterday's work and sends it to the team /linear fetch issues updated yesterday summarize what was completed and what's in progress /notify alert @team

That’s it. # Title sets the display name. Everything between the title and the first executable line is the description — no special syntax needed.

Legacy # name: / # description: headers also work, but the cleaner style above is preferred:

# name: Daily Standup # description: Summarizes yesterday's work

The two step types

There are really only two kinds of steps. Once you internalize this, everything else is just control flow around them.

App steps

Call an installed app’s action with /app action args:

/github fetch open PRs from myorg/myrepo /stripe payments from this week /linear fetch issues in project CORE /notify alert @team

The format is /appId actionName followed by whatever natural language the app needs. The text after the action name is free-form — the app interprets it however makes sense. If you skip the action name, the app just receives the bare command:

/stripe

LLM steps

Any line that isn’t a special keyword is an LLM step — plain language that AI processes using the previous step’s output:

filter to only critical issues summarize in three bullet points format as a markdown table translate to Spanish

LLM steps are the glue between app steps. Need to reshape data between two apps? Just say what you want in plain language. This is what makes Pipedown feel so different from traditional workflow tools — instead of writing transformation code, you describe the transformation.

For longer instructions, indent continuation lines. They get joined into a single instruction:

write a report covering: revenue trends from this quarter user growth metrics churn analysis and recommendations

A line is an LLM step if it doesn’t start with /, *, -, !, if, cancel, or a block name. Everything else is just a natural language instruction for AI.

Variables and data flow

Here’s where things get interesting. By default, each step’s output flows straight to the next step:

/github fetch open issues <- returns issue list filter to only bugs <- AI filters the list format as markdown table <- AI formats the filtered list /notify alert @devs <- sends the formatted table

This linear pipeline is clean and readable for sequential work. But what happens when step 5 needs the output from step 2? Or when you’re combining results from three parallel fetches? That’s where variables come in.

Setting a variable

Drop = variableName at the end of any step to capture its output:

/github fetch open issues = issues /stripe payments this week = revenue

Referencing a variable

Use {{variableName}} anywhere in a later step to inject that saved output:

/github fetch open issues = issues /stripe payments this week = revenue combine {{issues}} and {{revenue}} into a weekly summary /notify alert @team

Without variables, that combine step would only see the output of the /stripe step before it. Variables let you reach back and pull in anything from earlier in the pipeline.

Property access

When an app returns structured data, use dot notation to drill in:

/github fetch PR #42 = pr /notify alert @team that {{pr.title}} by {{pr.author}} is ready for review

Nested access works too: {{pr.author.login}}. If the variable holds JSON, {{variable.prop}} extracts the named property.

One thing to know: property access works when apps return structured data directly (like GitHub or Stripe). Apps that store data via the data[] mechanism (like Cloudflare or one-dollar-stats) return text output — use the variable value directly rather than dot-accessing properties.

Running steps in parallel

Sequential pipelines are great, but sometimes you need to fetch from three services at once. Prefix a step with * and it runs in parallel with its *-prefixed siblings:

* /github fetch open issues = issues * /stripe payments this week = revenue * /sentry issues = errors combine {{issues}}, {{revenue}}, and {{errors}} into a status report

All three fetches run concurrently. The next non-parallel step automatically waits for all of them to finish — it acts as an implicit barrier. Use variables to reference the outputs, since there’s no single “previous output” when multiple steps run at once.

* /stripe payments this month = current * /stripe payments last month = previous /data comparison stripe-revenue currentDays={{current}} previousDays={{previous}}

The /data step is sequential, so it naturally waits for both fetches.

Barriers between parallel groups

Usually the implicit barrier is all you need. But when you have two parallel groups where the second depends on results from the first, use ---:

* /github issues = issues * /github prs = prs * /github activity = activity --- * store daily series github-open-issues from {{issues}} * store daily series github-open-prs from {{prs}}

Without ---, all six * steps would form one parallel group — the store steps would run before {{issues}} and {{prs}} exist.

Sequential prefix

You can prefix a step with - for explicit sequential ordering. This is purely cosmetic — steps are sequential by default — but it can make a flow more readable:

- /github fetch open issues - filter to high priority - /notify alert @team

Blocks

As flows get larger, you want ways to organize them. Blocks give you that structure.

Separators

--- is a barrier between sections. Steps after a --- wait for everything above to complete:

* /cloudflare analytics last week = cf * /cloudflare dns = dns --- summarize traffic and note DNS changes from {{dns}} * store weekly series cf-weekly-requests from {{cf}} * if there were threats blocked /chat message cloudflare report: {{cf}}

A --- also resets conditional chains (if/else-if/else), so conditions after a barrier start fresh.

Named blocks

Give a group of steps a name with name = on its own line. The block’s final output is assigned to that variable:

gather = * /github fetch open issues = issues * /stripe payments this week = revenue report = combine {{issues}} and {{revenue}} into a summary /notify alert @team

Named blocks are really about readability — they let you scan a long flow and immediately see its structure. The variable name must start with a letter and can contain letters, numbers, hyphens, and underscores.

Each blocks

Sometimes you need to do the same thing to every item in a collection. each iterates over a variable’s output, running the indented body for each item:

/github fetch open issues = issues each {{issues}}: create a summary for this issue /notify alert @assignee

Each iteration receives its item as the implicit input to the first sub-step — you don’t need to reference it by name. The item flows through sub-steps just like any other pipeline input.

Iterate over literal values when you know the set upfront:

each frontend, backend, infra: /github fetch open issues from myorg/this-repo summarize blockers

Combine multiple variables with + to zip them together:

/github fetch repos = repos /sentry issues = errors each {{repos}} + {{errors}}: correlate errors with repos

Or use , to iterate over multiple refs independently:

each {{issues}}, {{prs}}: summarize this item

Inside an each block, sub-steps receive the current item as input, not the whole collection. Don’t use {{issues.title}} to access properties of the current item — let the AI extract what it needs from the input, or use an LLM step to reshape the item first.

* works on each the same way as any line — it makes the block parallel with its siblings:

/stripe payments = payments * /chat post summary to @team * each {{payments}}: /data insert payment-record /postmark email receipt

Here /chat and the each block run in parallel. Inside the body, steps are sequential. Want to parallelize within each iteration too? Use * on the inner steps:

* each {{repos}}: * /github fetch open issues * /sentry fetch errors

Conditionals

Flows need to make decisions. The conditional system is designed to feel natural — you describe conditions the way you’d explain them to a person, and AI evaluates them against the actual data.

if

Skip subsequent steps in the same block when a condition is false:

/stripe payments today if there are payments over $1000 /notify alert @finance about large payments

The condition is evaluated by AI. Any {{variable}} references are automatically resolved and shown to the AI alongside the condition:

/fetch health https://mysite.com = health if {{health}} shows any endpoints down /notify push @oncall site is down

The AI receives the full value of {{health}} as context — even though the syntax is concise, the AI has complete information to make an accurate decision.

For exact expression evaluation, use backticks:

if `{{revenue}} > 10000`

else if / else

Chain conditional branches. Only the first matching branch runs:

/github fetch open issues if there are critical bugs /notify alert @oncall about critical bugs else if there are high priority issues /notify alert @team about high priority items else summarize that everything looks good

Once a branch matches, the remaining branches are skipped. A --- separator or a new if resets the chain.

cancel if

Sometimes you want to bail out of the entire flow early. cancel if stops everything:

/github fetch open issues cancel if there are no issues summarize the issues /notify alert @team

With backtick expressions:

cancel if `{{count}} == 0`

Error handling

Things fail — APIs go down, rate limits hit, services timeout. Pipedown gives you three levels of error control, from lightweight to comprehensive.

Inline suffixes

The quickest way to handle errors — append ? or ! to any step:

  • ? — optional step, gracefully continues on failure
  • ! — must-succeed step, retries on failure
/stripe payments ? /notify push @channel {{summary}} !

? is shorthand for ! ignore. ! is shorthand for ! retry. For quick one-line error handling, these are all you need.

Retry

Retry the preceding step up to N times with exponential backoff (1s, 2s, 4s, capped at 5s):

/fetch get https://api.example.com/data ! retry 3

Ignore

Swallow the error and keep going:

/fetch get https://flaky-api.example.com ! ignore summarize whatever data is available

Fallback

When a step fails, run replacement steps instead. Indent the fallback body:

/fetch get https://primary-api.example.com ! fallback /fetch get https://backup-api.example.com summarize the data

If the primary fetch fails, the indented fallback steps run and the flow continues with their output. If the fallback also fails, the flow stops.

Wait

Some things need a human in the loop. /wait pauses a flow until someone approves or a timer expires.

Wait for approval

/wait @cathy

The flow pauses until Cathy approves. If she denies, the flow fails.

Wait for a duration

/wait 2h /wait 30m /wait 1d

Wait for approval with timeout

/wait @cathy 24h

Wait for Cathy to approve, but auto-continue after 24 hours if no response.

Data storage

Flows don’t just transform data — they can persist it. The workspace data store lets flows save values for display in blocks or later querying.

Auto-storage

Many apps automatically store their results when they run. No explicit /data step needed:

/one-dollar-stats daily

The daily stats are auto-stored and immediately queryable from blocks:

<Number query={{ app: "one-dollar-stats", type: "dailyStats", sort: "newest" }} extract="visitors" label="Visitors" />

Manual storage with /data

Use /data upsert to store a named stat, and /data series to append a time-series data point. Specify the value explicitly with value=:

/stripe payments today = payments /data upsert stripe-daily type=stat value={revenue: {{payments.revenue}}, count: {{payments.count}}} /data series stripe-revenue value={{payments.revenue}}

The value= argument accepts:

  • A variable reference: value={{payments.revenue}} (extracts property from structured output)
  • An object literal: value={revenue: {{payments.revenue}}, count: {{payments.count}}}
  • A plain string: value=up

Without value=, the data app uses AI to extract a numeric value from the current pipeline input — useful but less reliable. For precision, always specify value= explicitly.

Querying stored data in blocks

Blocks query stored data via:

  • query="key" — fetch a named entry
  • query={{ app: "appId", type: "typeName", sort: "newest" }} — filter by app and type
  • query={{ category: "name", type: "series" }} — fetch time-series data

The extract prop pulls a specific property from the stored value object.

Common patterns

Here’s the thing about Pipedown’s minimal syntax — the same small set of features composes into patterns that handle surprisingly complex scenarios. These aren’t special constructs you need to learn. They emerge naturally from what you already know.

Gather and report

The parallel + variables pattern. Fetch from multiple sources at once, then combine:

gather = * /github fetch PRs merged this week = prs * /stripe payments this week = revenue report = combine {{prs}} and {{revenue}} into an executive summary /notify alert @team

Conditional notification

Guard with cancel if to bail early when there’s nothing to report:

/sentry issues cancel if there are no unresolved issues filter to critical severity /notify alert @oncall

Retry with fallback

Layer error handling for critical paths — try the primary, retry twice, then fall back to a backup:

/fetch get https://primary-api.example.com/data ! retry 2 ! fallback /fetch get https://backup-api.example.com/data ! ignore use cached data if available

Approval gate

Human-in-the-loop before irreversible actions:

/stripe refunds pending review = refunds cancel if no refunds pending format {{refunds}} as a summary for review /wait @finance 24h /stripe process approved refunds

Putting it all together

This is where everything clicks. Read through this flow and notice how every concept from this page shows up naturally — the header, parallel fetches, variables, named blocks, conditionals, wait, and notification. Nothing feels bolted on because it’s all the same small syntax composing together:

# Weekly Engineering Report Gathers data from multiple sources and sends a formatted report every week friday 17:00 gather = * /github fetch PRs merged this week from myorg/myrepo = prs * /sentry stats = errors * /linear fetch issues closed this week = completed report = combine {{prs}}, {{errors}}, and {{completed}} into a weekly engineering report format with sections for "merged PRs", "error trends", and "completed work" review = cancel if the report is empty /wait @lead 4h /notify alert @engineering

A scheduled flow that gathers from three services in parallel, combines the results with AI, gets human approval (with a timeout), and delivers the report. Twelve lines of plain text.

Syntax reference

Every element on one page. Keep this open while you’re writing.

ElementSyntaxExample
Header (name)# Title# Daily Digest
Header (description)plain text after titleSends a daily summary
App step/app action args/github fetch open PRs
LLM stepplain textsummarize in bullet points
Variable (set)= name at end of step/stripe payments = rev
Variable (reference){{name}} in step textcombine {{issues}} and {{rev}}
Variable (property){{name.prop}} in step textsend {{pr.title}} to team
Named blockname = on its own linegather =
Parallel* prefix* /github fetch issues
Sequential- prefix- /stripe payments
Separator------
Each (variable)each {{var}}: with indented bodyeach {{issues}}:
Each (literals)each a, b: with indented bodyeach prod, staging:
Each (combine)each {{a}} + {{b}}: with indented bodyeach {{repos}} + {{errors}}:
Ifif conditionif there are results
If (expr)if `expression`if `{{count}} > 0`
Else ifelse if conditionelse if there are warnings
Elseelseelse
Cancel ifcancel if conditioncancel if no data
Cancel if (expr)cancel if `expression`cancel if `{{total}} == 0`
Optional step? suffix/stripe payments ?
Must-succeed step! suffix/notify push @team !
Retry! retry N! retry 3
Ignore errors! ignore! ignore
Fallback! fallback! fallback
Wait (person)/wait @person/wait @cathy
Wait (duration)/wait duration/wait 2h
Wait (both)/wait @person duration/wait @cathy 24h