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.

step A -> "here's some sales data..." -> step B -> "top 3 sales were..." -> step C

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:

const message = await tools.generate.text(
'sm',
`Given this sales data:\n${input.summary}\n\n` +
`The user wants: "${input.instruction}"\n\n` +
`Write a Slack message for the #wins channel.`,
)

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:

{
instruction: string // NLP text after /app action — the user's instruction
summary: string // the string output from the previous step
data: AppResultDataItem[] // structured data items from the previous step
}

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:

  1. Determine execution path: block-based (when any block has each) or flat (all other flows, including those with labels but no each)
  2. Iterate through steps, tracking status as each completes
  3. Resolve to a final status: complete, failed, cancelled, or waiting

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:

* /stripe sales last week = sales * /posthog signups last week = signups * /github issues opened last week = issues --- summarize {{sales}}, {{signups}}, and {{issues}} into a weekly digest

Variables

Variables are named references to step outputs. References are resolved before passing args to each step:

{{revenue}} -> full output of the assigned step {{revenue.total}} -> property access on a JSON-parseable output

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:

  1. Resolves items from the referenced variables
  2. Runs sub-steps for each item — parallel if the block is marked *, sequential otherwise
  3. 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:

/stripe payments this week ! retry 3 <- retry up to 3 times (exponential backoff: 1s, 2s, 4s) /github fetch open issues ! ignore <- swallow failure, continue with empty output /posthog events today ! fallback <- if this fails, run fallback sub-steps instead /notify alert posthog step failed

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:

{
name: string
description: string
runsOn?: 'server' | 'desktop'
run: (context: AppContext) => Promise<string | AppResult>
}

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 surface
  • oauthToken — if the app authenticated via OAuth
  • options — 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
  • Aborttools.abort(reason) to stop the pipeline cleanly
  • Progresstools.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: defineConfig for runtime configuration

To build a complete runtime, you plug in five things:

  1. An LLM provider — for AI steps and app-internal generation
  2. An app registry — which apps are installed and where their source lives
  3. A scheduler — for every ... triggers
  4. An event bus — for on ... triggers
  5. 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

trigger fires (schedule / event / manual) | load and parse program | has each blocks? yes -> block-based execution path no -> flat step loop flat step loop: sequential step: built-in? -> handle inline (if/else/wait/cancel/llm/---/message) app step? -> dispatch to app runtime -> app executes action with input + tools -> returns string output assign? -> store variable output error? -> retry / ignore / fallback SKIP? -> break pipeline parallel group: -> execute all parallel steps concurrently -> join outputs | flow status: complete | failed | cancelled | waiting