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:

  1. Load the flow record from Postgres
  2. Create a flowRun row with status: 'running' and a stepsStatus array (one entry per step, initially idle)
  3. Pick the execution path: block-based (when any block has each) or flat (everything else, including flows with labels but no each)
  4. Walk through steps, updating stepsStatus as each completes
  5. Write final status: complete, failed, cancelled, or waiting

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:

StateTypePurpose
lastOutputstringstring output of the previous step
lastDataAppResultDataItem[]structured data items from the previous step
tagMapMap<string, string>named outputs assigned with = variable
tagDataMap<string, AppResultDataItem[]>structured data items for assigned variables
blockOutputsMap<string, string>variable block outputs for each resolution
contextObjectRecord<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 on xs/sm, and expansion starts at sm and can escalate to lg for very large context
  • --- separator — resets conditional chain state (if/else tracking)
  • if / else-if / else — sends the condition and current lastOutput to the xxs LLM tier with a YES/NO prompt. Backtick syntax evaluates a JS expression instead
  • cancel — sends the condition + current lastOutput to xxs. If YES, sets lastOutput = 'SKIP' and breaks the step loop
  • message — posts to the flow thread directly, without going through the /chat app 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 a waitCondition on the flowRun record 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:

  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

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

trigger fires (schedule / event / manual) | runFlow({ flowId, userId, input? }) | create flowRun record (status: running) | has each blocks? yes -> runBlocksWithEach (block-based path) no -> flat step loop flat step loop: sequential step: built-in? -> handle inline (if/else/wait/cancel/---) llm/message/app? -> dispatchStep(step, args, previous) -> isolated-vm executes app action -> tools callbacks bridge to host -> returns string output assign? -> store variable output error? -> retry / ignore / fallback SKIP? -> break pipeline parallel group: -> Promise.allSettled(all parallel steps) -> join outputs -> lastOutput | update stepsStatus[i] = complete | failed | flowRun status: complete | failed | cancelled | waiting

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 OAuth
  • options — 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.

ToolPurpose
tools.data.public / .privateStore 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.pushNotifications
tools.webhooksWebhook 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 / randomIdUtilities
tools.crypto.signJWT / base64urlCrypto 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:

// bad -- cached ISO date becomes stale
task: `Parse: "today"\nCurrent time: ${new Date().toISOString()}`
// good -- relative type cached, resolved in action code
schema: { relative: '"today"|"yesterday"|"last-n-days"', n: '?number' }

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-apps 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. 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: defineConfig for runtime configuration

A standalone runtime (@pipedown/cli) would need to plug in:

  1. An LLM provider (for AI steps and app-internal generation)
  2. An app registry (which apps are installed, their source)
  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. See Tooling for CLI, editor, and syntax highlighting support.