Execution Model
The abstract pipedown runtime spec — what any implementation must satisfy to run .pd programs
Pipedown is to workflows what Markdown is to documents.
Markdown defines a syntax. Then CommonMark nails down the spec. Then every renderer — GitHub, Obsidian, VS Code, your static site generator — implements that spec their own way. The syntax is portable. The rendering is not.
Pipedown works the same way. The
language spec defines what a .pd program looks
like. This page defines the execution contract — what any runtime must do to
faithfully run one. start.chat has
its own implementation with sandboxing, persistence,
and a full tools API. But nothing here is start.chat-specific.
If you’re writing flows, you want Writing Flows or How it Runs. This page is for people building a pipedown runtime, or who want to deeply understand the execution contract.
The big idea: string in, string out
Every step receives a string. Every step returns a string. That’s the pipe.
No schemas. No field mapping. No type negotiation between steps.
If you’ve ever used a traditional workflow tool, you know the pain: “map
output.customer_id to input.id”. It’s the hardest part of any workflow
system, and it breaks the moment an upstream step changes its output shape.
Pipedown doesn’t have this problem because it doesn’t try to solve it at the pipe level. Instead, it pushes the problem somewhere much better equipped to handle it.
Why this works: AI inside apps handles parsing
This is the core design bet.
When /slack post in #wins top 3 highest selling receives the previous Stripe
output, it doesn’t need a schema. The Slack app calls something like:
The LLM inside the app handles the transformation. The pipe doesn’t know or care what shape the data is in.
This has profound consequences:
- Apps compose freely regardless of what they return
- Plain english steps work naturally (“summarize the above”)
- Adding a step between existing steps requires no plumbing — no field mapping to update, no schemas to reconcile
- Apps can produce structured API calls internally via
tools.generate.object()without exposing that schema to the pipe
The pipe carries strings. Intelligence lives inside the apps. That’s the whole trick.
The input object
Inside an action, the runtime provides:
Apps typically use instruction when they need the step’s literal arguments.
They use summary when doing transformations on piped data. They use data
when they need structured items (each with type, key, value, etc.).
Data alongside strings
Steps can return structured data (an array of AppResultDataItem) alongside
the string output. The runtime keeps this and passes it to the next app as
input.data. Each data item has type, key, value, and optional title,
status, kind fields.
The string pipeline is always canonical — data items are supplementary context, not a parallel channel.
How a program executes
A runtime receives a trigger (manual, scheduled, or event-based), loads the parsed program, and walks through the steps:
- Determine execution path: block-based (when any block has
each) or flat (all other flows, including those with labels but noeach) - Iterate through steps, tracking status as each completes
- Resolve to a final status:
complete,failed,cancelled, orwaiting
That’s intentionally simple. Most of the interesting behavior comes from how individual step types are handled.
Built-in step types
These are handled directly by the runner, not dispatched to apps. Any pipedown runtime must implement all of them.
LLM step (llm / bare text)
The parser converts bare text to { app: 'llm', action: 'do', args: text }
before the runner sees it. Writing summarize into three bullet points and
/llm do summarize into three bullet points produce the same parsed step.
The runner handles llm steps inline, calling an LLM with the step’s args as
the prompt and the previous output as input. The runtime chooses an appropriate
model based on task complexity.
--- separator
Resets the conditional chain state (if/else tracking). In the flat execution
path, that is all it does.
Parallel outputs are joined by \n---\n as a result of the parallel group
completing, not by the --- step itself. The separator serves as a visual
boundary in the source that corresponds to the natural join point after parallel
blocks, but the runtime joining happens automatically when the parallel group
finishes.
The conceptual model (“--- waits for everything above and gives you one string
to work with”) is accurate from the author’s perspective.
if / else-if / else
Evaluated by sending the condition and current output to an LLM with a YES/NO prompt. The result gates whether following steps run.
For deterministic conditions, backtick syntax evaluates a JS expression:
if `input.includes('deploy')`
Conditionals with indented body steps (if/else if/else followed by
indented lines) execute those body steps only when the branch condition is true.
The body steps are stored in the conditional step’s body field.
cancel
Sends the condition and current output to an LLM asking YES/NO. If YES, the
pipeline stops cleanly. Apps can also cancel the pipeline by calling
tools.abort() or returning the string 'SKIP'.
message
A lightweight built-in for posting to the flow’s output context (e.g. a thread
or log). Uses previous output as content if args are empty. Skipped silently if
content resolves to empty or 'SKIP'.
wait
Pauses the flow run. Two forms:
/wait 2h— resumes after a duration/wait @person— creates an approval gate; a human must approve/deny
When a wait step is hit, the runtime serializes current state and exits cleanly. On resume, state is restored and execution continues from the step after the wait.
Parallel execution
Lines prefixed with * run in parallel. The runner groups consecutive parallel
steps and executes them concurrently. Control-flow builtins (if, else-if,
else, cancel, llm, error, wait) cannot be parallel — they are always
sequential.
After a parallel group, all outputs are joined and become the next step’s input. Variables assigned within parallel steps are available to later steps:
Variables
Variables are named references to step outputs. References are resolved before passing args to each step:
Variable blocks (week =, month =) are automatically added to the variable
map after that block’s last step completes.
Each iteration
each {{week}}, {{month}} iterates steps once per item. The runtime:
- Resolves items from the referenced variables
- Runs sub-steps for each item — parallel if the block is marked
*, sequential otherwise - Collects all results, joined as
\n---\n
each {{week}} + {{month}} combines two collections before iterating.
each {{runs}} iterates over a variable value that resolves to multiple lines
or a JSON array.
When the each expression uses NLP (nlp field present), the runtime resolves
any extracted refs first, then passes the expression to the LLM for
interpretation.
When a block has each, the runner takes the block-based execution path. Flows
without any each blocks always use the flat path, even if they have named
variable blocks.
Error handling
Error directives follow the step they apply to. The runner scans ahead to find attached handlers before running the step:
Fallback sub-steps receive the same input the failed step would have received. If no error handler is present and a step fails, the flow fails and stops.
Apps can also call tools.abort(reason) to explicitly stop the pipeline — this
is caught by the runner and treated as a clean SKIP.
Triggers
manual — run on demand via UI or API.
schedule — run on a cron-like schedule. Parsed from lines like
every monday morning or every day 08:00. Supported frequencies: 5-minutes,
15-minutes, 30-minutes, hour, day, week, month.
event — triggered by app events. on message fires on every new message.
on github push fires when the GitHub app receives a push webhook.
Event triggers pass the event payload as the initial input (JSON stringified),
so the first step receives raw event data as its summary value.
How apps provide actions
This is where runtime builders need to pay attention. The app contract is what
makes pipedown extensible — the language itself is fixed, but the vocabulary of
available /commands is entirely determined by installed apps.
Apps are JavaScript modules that export an actions object. Each action has:
The runtime provides each action with:
input—{ instruction, summary, data }(the step’s place in the pipe)context— workspace context (server, channel, thread identifiers)tools— the runtime’s platform API surfaceoauthToken— if the app authenticated via OAuthoptions— install-time configuration set by admin
The return value can be a plain string, or an object
{ summary, data?, ...rest }. The runner uses summary as the next step’s
input and rest as supplementary structured data.
The minimum tools surface
Every action receives a tools object from the runtime. The exact tools available depend on the implementation, but a conforming runtime must provide at minimum:
- Data storage — ability to store and query structured data
- AI generation — text and JSON generation with model tier selection
- Messaging — ability to post messages to output contexts
- Abort —
tools.abort(reason)to stop the pipeline cleanly - Progress —
tools.progress(0-100)to update progress indicators
Runtimes may provide additional tools (webhooks, notifications, crypto helpers, file access, etc.). See How it Runs for start.chat’s full tools API.
The ecosystem model
/stripe, /slack, /linear are only valid because those apps are installed
and export those actions. This means:
- The runtime itself never needs to know about Stripe or Slack
- Adding a new integration adds new vocabulary without changing the language
- An org can build internal apps that define custom actions
- An AI assistant can query the app registry to discover what’s installed before generating pipedown, so it only emits valid steps
Action description strings are what the AI uses to decide which action to call. Good descriptions let the AI author flows correctly.
Building a standalone runtime
The pipedown language is fully decoupled from any specific runtime. The
@pipedown/core package gives you the foundation:
- Parsing:
parse,parseStep,parseBlocks,extractFromCodeBlock - Serialization:
serialize,serializeStep - Types:
PipedownProgram,PipedownBlock,PipedownStep,PipedownTrigger,PipedownEach - Editor tooling: Monaco integration, Shiki syntax highlighting, JSON schema
- Config:
defineConfigfor runtime configuration
To build a complete runtime, you plug in five things:
- An LLM provider — for AI steps and app-internal generation
- An app registry — which apps are installed and where their source lives
- A scheduler — for
every ...triggers - An event bus — for
on ...triggers - A storage backend — for
tools.data.*
The parser and step types are the stable interface. Everything else is a runtime concern — which is exactly the point of the Markdown analogy. The syntax is portable. The renderer is yours.
Execution lifecycle