Platform Runtime
How start.chat brings pipedown to life — persistence, sandboxing, real-time audit trails, and the full tools API
The pipedown runtime spec describes an abstract machine: give it a program, some apps, and an LLM, and it runs. But it says nothing about where state lives, how apps are isolated, or what happens when something fails at 3am on a Saturday.
That’s what start.chat’s runtime actually solves.
Every flow run is a Postgres row. Every step posts to a chat thread. Every app runs in a sandbox. Every schedule ticks on a database clock. And everything syncs to every client through Zero.
This page documents those internals. For the abstract execution model, see Runtime. For the language itself, see Language Spec.
How a flow actually runs
runFlow is the entry point. When a trigger fires — manual click, cron tick,
or incoming webhook — here’s what happens:
- Load the flow record from Postgres
- Create a
flowRunrow withstatus: 'running'and astepsStatusarray (one entry per step, initiallyidle) - Pick the execution path: block-based (when any block has
each) or flat (everything else, including flows with labels but noeach) - Walk through steps, updating
stepsStatusas each completes - Write final status:
complete,failed,cancelled, orwaiting
Because the flowRun row syncs through Zero in real time, every client watching
that flow sees step-by-step progress without polling.
Runner state
State threads through every step as the flow executes:
| State | Type | Purpose |
|---|---|---|
lastOutput | string | string output of the previous step |
lastData | AppResultDataItem[] | structured data items from the previous step |
tagMap | Map<string, string> | named outputs assigned with = variable |
tagData | Map<string, AppResultDataItem[]> | structured data items for assigned variables |
blockOutputs | Map<string, string> | variable block outputs for each resolution |
contextObject | Record<string, unknown> | accumulated cross-step context |
Between steps, lastOutput flows forward automatically. Every step receives the
previous step’s string output as input.summary.
Step dispatch
Built-in steps are handled inline by the runner — no sandbox, no isolation, just direct execution:
- LLM (
llm/ bare text) — picks a lightweight model tier per call site: analysis snippets stay onxs/sm, and expansion starts atsmand can escalate tolgfor very large context ---separator — resets conditional chain state (if/elsetracking)if/else-if/else— sends the condition and currentlastOutputto thexxsLLM tier with a YES/NO prompt. Backtick syntax evaluates a JS expression insteadcancel— sends the condition + currentlastOutputtoxxs. If YES, setslastOutput = 'SKIP'and breaks the step loopmessage— posts to the flow thread directly, without going through the/chatapp sandbox. Uses previous output as content if args are empty. Skipped silently if content resolves to empty or'SKIP'wait— serializes current state (lastOutput, lastData, tagMap, tagData, step index) into awaitConditionon theflowRunrecord and exits cleanly. On resume, state is restored and execution continues from the step after the wait
App steps get dispatched to runApp, which spins up an isolated-vm sandbox (see
below) and passes the step’s input.
Parallel execution
Consecutive *-prefixed steps are grouped and executed with
Promise.allSettled. After the group completes, all outputs are joined with
\n---\n and stored in lastOutput. Variables assigned within parallel steps
are available to later steps. Control-flow builtins (if, else-if, else,
cancel, llm, error, wait) cannot be parallel — they are always
sequential.
Each iteration
each {{var1}}, {{var2}} is handled by runBlocksWithEach, which:
- 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
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. Flows without any each blocks always use the flat path.
Error handling
The runner scans ahead to find error directives attached to each step before running it:
! retry N— retry up to N times with exponential backoff (1s, 2s, 4s)! ignore— swallow failure, continue with empty output! fallback— run fallback sub-steps instead (they receive the same input the failed step would have received)
If no handler is present and a step fails, the flow marks status: 'failed' and
stops. Apps can also call tools.abort(reason) to explicitly stop the pipeline
— this throws __APP_ABORT__ which the runner catches and treats as a clean
SKIP.
Lifecycle at a glance
App sandboxing
Apps are user-authored code. They call external APIs, parse arbitrary data, and run AI-generated snippets. You really don’t want that executing on the same thread as your database connection.
The sandbox is the trust boundary. First-party or third-party, every app runs in the same isolated container with the same constraints.
isolated-vm
Each app action runs in a fresh isolated-vm isolate — a V8 context with its
own heap, completely separated from the host process. The runtime maintains a
pool of up to 4 reusable isolates to amortize startup cost.
Why isolated-vm instead of workers or child processes? Memory isolation.
A misbehaving app can’t touch host memory, can’t access the file system, and
can’t exhaust host resources. The V8 isolate enforces this at the engine level,
not with permission flags.
The START_CONTEXT global is injected into the isolate with:
input—{ instruction, summary, data }(the step’s place in the pipe)context—{ serverId, channelId, threadId, object }(workspace context)tools— the full platform API (see below)oauthToken— if the app authenticated via OAuthoptions— install-time configuration set by server admin
The return value can be a plain string, or an object
{ summary, data?, ...rest }. The runner uses summary as lastOutput and
data as lastData.
Callbacks and polyfills
The isolate can’t access host resources directly — that’s the point. Callbacks
bridge out to host functions for database access, message posting, and LLM
calls. The sandbox calls tools.data.public.set(...), which crosses the
isolation boundary, executes on the host, and returns the result back.
Standard polyfills available inside the sandbox: fetch, URL,
URLSearchParams, TextEncoder/Decoder, Buffer, btoa/atob, console.
Secrets
First-party apps may receive env var secrets via tools.secrets. Third-party
apps get no env secrets — they store credentials in tools.data.private.
Tools API
Every action receives the same tools surface, regardless of whether it’s a first-party integration or something you wrote yesterday. For the full reference with code examples, see Tools API.
| Tool | Purpose |
|---|---|
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 -> structured object |
tools.strategy.list(opts) | Cached AI -> array |
tools.strategy.data(opts) | Cached AI -> standardized data items |
Learn once, run forever
The strategy methods (tools.strategy.object(), .list(), .data()) are one
of the more interesting pieces of the runtime. On first run, the strategy system
uses AI to generate a JS snippet that maps input to output. It caches that
snippet. Every subsequent run replays it — no AI call, instant, deterministic.
If the cached snippet fails, the system self-heals: passes the failed code and error to the AI, which generates a corrected version automatically.
One critical rule: strategies must be time-independent. They’re saved and
reused — never bake timestamps into the task. Cache the shape of the
operation and resolve concrete values at runtime:
Use forceRegenerate: true to invalidate a cached strategy. See
Strategies for the full reference.
The apps ecosystem model
Here’s a subtle but important point: the vocabulary of a pipedown program is
entirely defined by installed apps. /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
- The AI flow assistant calls
flow-list-appsto 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. See Creating Apps for the full app definition API and App Actions for available actions by app.
Infrastructure
Persistence
Flow and flowRun records live in Postgres and sync to every client via
Zero. The flow table holds the pipedown source and trigger config. The
flowRun table tracks each execution: status, stepsStatus array,
waitCondition, and timing.
Because this is all in Zero, the UI shows live run status, step progress, and
history without any custom WebSocket plumbing. A flow run updating its
stepsStatus array is just a Postgres row change that Zero picks up and pushes.
Real-time audit trails
Each flow gets a dedicated chat thread in the Flows channel. As a flow runs, step outputs are posted to the thread in real time. This means your team can watch a flow execute, scroll back through previous runs, and debug failures — all in the same chat interface they’re already using.
No separate logging dashboard. The audit trail is the conversation.
Scheduling
Scheduled triggers (every monday morning, every 15min) are backed by a
Postgres-based scheduler. No external cron service, no Redis queues — just
a table of scheduled jobs that the server polls. Supported frequencies:
5-minutes, 15-minutes, 30-minutes, hour, day, week, month.
Event triggers
Event triggers (on stripe, on stripe.payment, on github.push) fire when
an app’s webhook handler returns a matching event. Webhook routes receive
external payloads, dispatch to the app’s onWebhook handler, and trigger any
flows listening for that event. The event payload is passed as the initial
lastOutput (JSON stringified), so the first step receives raw event data.
Standalone pipedown
Everything above is start.chat-specific, but the pipedown language itself is
decoupled. The @pipedown/core package provides:
- 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
A standalone runtime (@pipedown/cli) would need to plug in:
- An LLM provider (for AI steps and app-internal generation)
- An app registry (which apps are installed, their source)
- 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. See Tooling for CLI, editor, and syntax highlighting support.