How It Runs
String in, string out — AI inside apps handles the rest
Most workflow tools make you do plumbing. Map this field to that field. Define the output schema of step 1 so step 2 knows what to expect. Change one step and watch three downstream connectors break.
Pipedown throws all of that away. The execution model is radically simple:
Every step receives a string. Every step returns a string. That’s the entire contract between steps.
No schemas. No field mapping. No type negotiation. Just text flowing through a pipe, with AI inside each app handling the interpretation. This is the key design insight that makes everything else work.
Why strings?
Think about how you’d explain a task to a colleague over chat. You wouldn’t send them a JSON schema — you’d send them a message. “Here are the top payments from last week, can you post the highlights to #wins?”
That’s exactly how pipedown steps communicate. The output of /stripe payments this week is a human-readable summary. The next step — whether it’s an LLM
step like filter to the top 3 or an app step like /slack post in #wins —
reads that summary and figures out what to do with it.
This works because each app has AI built into its runtime. When
/slack post in #wins top 3 highest selling receives Stripe’s output, it
doesn’t need a field mapping. It calls something like:
The LLM inside the app handles the transformation. The pipe never knows or cares what shape the data is in.
Traditional workflows break when upstream steps change their output shape. Pipedown steps compose freely because each app interprets its own input.
This is also why plain-english steps work so naturally — summarize the above
is just another step that receives a string, does something smart with it, and
returns a string.
And it gets better: apps don’t call AI on every run. On first execution, the strategy system uses AI to generate a JS snippet that handles the mapping. It caches that snippet and replays it deterministically from then on — no AI call, instant execution.
The input object
Inside an action, the step’s context looks like this:
instruction is what the flow author wrote — the arguments after /app action.
summary is what the previous step returned — the string flowing through the
pipe. data is structured items that ride alongside the string, each with
type, key, value, and optional title, status, kind fields.
The string pipeline is always canonical. Data items are supplementary context, not a parallel channel — they’re there when an app needs structured access to the previous step’s output, but the string is what flows forward.
What the runner actually does
When a flow is triggered — manually, on schedule, or by an event — the runner walks through the steps one at a time. Each step gets the previous step’s output, does its thing, and hands off to the next.
The runner itself is simple. It maintains a handful of values as it moves through the pipeline:
lastOutput— the string output of the previous step (this issummary)lastData— structured data items from the previous steptagMap— named outputs from= variableassignmentscontextObject— accumulated context across steps
Between steps, lastOutput flows forward automatically. That’s the pipe.
How steps get dispatched
Some steps are handled directly by the runner — these are the built-in constructs that control flow rather than doing work:
- LLM steps (bare text) — sent to an AI model with the step’s text as the
prompt and
lastOutputas context if/else-if/else— the condition and current output go to a lightweight model that answers YES/NO. Backtick syntax (if `input.includes('deploy')`) evaluates JS insteadcancel— same YES/NO evaluation; if YES, the pipeline stops cleanlywait— serializes current state and pauses. A duration (/wait 2h) or human approval (/wait @person) resumes it latermessage— posts to the flow’s output thread---separator — resets the conditional chain
App steps — anything starting with /app — get dispatched to the app sandbox,
which is where things get interesting.
App sandboxing
Every app action runs in its own isolated sandbox. This isn’t just good practice — it’s fundamental to the model. Apps are code from different authors, potentially third-party, running with access to your workspace data. Isolation means:
- One app can’t access another app’s memory or secrets
- A buggy app can’t crash the runtime or affect other steps
- Third-party apps get a controlled surface area, not full host access
Under the hood, each action runs in a fresh
isolated-vm isolate — a V8
sandbox with its own heap, separate from the host process. The runtime keeps a
small pool of reusable isolates so startup stays fast.
The sandbox gets a START_CONTEXT global with everything the app needs:
input—{ instruction, summary, data }(its place in the pipe)context—{ serverId, channelId, threadId, object }(workspace info)tools— the full platform API (see below)oauthToken— if the app uses OAuthoptions— install-time config set by the server admin
The isolate can’t reach host resources directly. When an app calls
tools.data.public.set() or tools.generate.text(), those are callback
bridges — the call goes out to the host, executes there, and the result comes
back into the sandbox. Standard web APIs (fetch, URL, TextEncoder,
Buffer, console, etc.) are polyfilled inside the sandbox.
For secrets: first-party apps can access env vars via tools.secrets.
Third-party apps store credentials in tools.data.private — they never see
your environment.
The tools API
Every action gets the same tools surface. This is the platform API that apps use to interact with the world. For full reference with code examples, see Tools API.
| Tool | What it does |
|---|---|
tools.data.public / .private | Store and query structured data |
tools.generate.text(tier, prompt) | Generate text with model tier selection |
tools.generate.object(tier, prompt, schema) | Generate and parse structured JSON |
tools.message.insert(opts) | Post messages to channels |
tools.notify / tools.notify.push | Notifications |
tools.webhooks | Webhook management (auto-registered via flow event triggers) |
tools.abort(reason) | Stop the pipeline cleanly |
tools.progress(0-100) | Update progress indicator |
tools.flows.create(pipedown) | Programmatically create flows |
tools.helpers.slugify / randomId | Utilities |
tools.crypto.signJWT / base64url | Crypto helpers |
tools.files.* | File system access (desktop only) |
tools.strategy.object(opts) | Cached AI to structured object (strategies) |
tools.strategy.list(opts) | Cached AI to array (strategies) |
tools.strategy.data(opts) | Cached AI to standardized data items (strategies) |
The strategy methods deserve special attention — they’re how apps avoid calling AI on every run. See Strategies for the full story.
Parallel execution
Lines prefixed with * run concurrently. The runner groups consecutive
parallel steps and fires them all at once:
All three fetches happen simultaneously. When the group finishes, outputs are
joined and become the next step’s input. Variables assigned within parallel
steps are available to later steps. Control-flow builtins (if, cancel,
wait, etc.) can’t be parallel — they’re always sequential.
Error handling
Things fail. APIs go down, rate limits hit, data comes back weird. Pipedown gives you inline error handling right after any step:
! retry N— retry with exponential backoff (1s, 2s, 4s…)! ignore— swallow the failure, continue with empty output! fallback— run alternative steps instead
No handler and a step fails? The flow fails and stops. Apps can also call
tools.abort(reason) to cleanly exit the pipeline on purpose.
Each step also has a 2-minute timeout. If a step runs longer than that, it’s
cancelled and the flow fails. Break long operations into smaller steps or use
tools.progress() to track progress within the limit.
The apps ecosystem
Here’s what makes pipedown genuinely extensible: the language doesn’t know about
any specific service. /stripe, /slack, /linear are only valid because
those apps are installed. The runtime never needs to know about Stripe or Slack
directly.
This means:
- Adding an integration adds vocabulary. Install the GitHub app and suddenly
/github fetch issuesworks in any flow. No language changes needed. - Orgs can build internal apps. Your custom
/deployor/oncallapp works exactly like a first-party integration. - AI knows what’s available. The flow assistant queries the app registry to discover installed apps before generating pipedown, so it only emits valid steps.
- Descriptions drive discovery. Action description strings are what the AI uses to pick the right action. Good descriptions make AI-authored flows work correctly. See Creating Apps for the full app definition API.
Infrastructure
Flows aren’t just scripts that run and disappear. They’re persistent, visible, and auditable.
Persistence — flow and run records live in Postgres, synced to clients via
Zero. The flow table holds pipedown source and trigger config. The flowRun
table tracks each execution with status, step-by-step progress, and timing.
Execution logs — each flow gets a dedicated chat thread in the Flows channel. Step outputs post to the thread as the flow runs, giving everyone a real-time audit trail. You can watch a flow execute in chat just like you’d watch a colleague work.
Scheduling — every monday morning and every 15min triggers are backed
by a Postgres-based scheduler. Supported frequencies: 5-minutes, 15-minutes,
30-minutes, hour, day, week, month.
Event triggers — on stripe.payment fires when Stripe’s webhook handler
returns a matching event. The event payload becomes the first step’s input (JSON
stringified), so you’re immediately working with real data.
Going deeper
This page covers how flows work from a user’s perspective. If you want to go further:
- Pipedown Runtime — the abstract execution model that any pipedown runtime implements, independent of start.chat
- Implementation — start.chat-specific internals:
runner state, step dispatch,
isolated-vmpool details - Strategies — the learn-once-run-forever system that makes flows fast after first execution
- Writing Flows — full syntax reference