Strategies

Learn once, run forever

What if the AI only needed to figure things out once?

That’s the core insight behind strategies. Most flow steps do the same kind of work every time they run: “this field maps to that parameter,” “parse this instruction into these API params.” The shape of the operation doesn’t change between runs. But calling AI on every execution is slow, expensive, and non-deterministic.

Strategies let you learn once, run forever. First run: AI generates a JavaScript snippet that maps input to output. Every run after: the cached snippet replays instantly. No AI call. Deterministic. Same result every time.

And if the cached snippet ever breaks? The system self-heals — passes the failed code and error back to the AI, which generates a corrected version automatically. You get AI-quality flexibility with hardcoded-speed execution.

Why this matters

Traditional workflow tools solve the schema problem by requiring typed connections between steps. Pipedown takes a different approach — it pipes plain strings between steps and lets AI inside each app figure out what they mean.

That’s powerful, but naive: you don’t want to pay for AI parsing on every single run. A flow that checks Stripe every 15 minutes would make 96 AI calls per day just for parsing.

Consider a step like /stripe payments this week. The Stripe app needs to turn that natural language into an API call. Without strategies, you have two choices:

  1. Hardcode parsing — write rules for every possible phrasing. Brittle, incomplete, and every new phrase requires code changes.
  2. Call AI every time — works for any phrasing, but adds latency and cost to every single run.

Strategies give you the best of both. AI-quality understanding on first run, hardcoded-speed execution on every run after. Flows compose as freely as Unix pipes, handle natural language naturally, and run at near-zero marginal cost after the first execution.

How it works

All three strategy methods share the same core loop:

first run: input -> [AI generates JS snippet] -> [execute] -> save code + result -> return cached run: input -> [load cached JS] -> [execute] -> return failure: input -> [load cached JS] -> [execute] -> ERROR -> [regenerate with error + failed code] -> [execute] -> save new code -> return

For object() and list(), the generated JS receives { instruction, summary, data }. For data(), it receives the raw data directly.

Picking a strategy method

All strategy methods live on tools.strategy and share the same core behavior: generate a JS snippet on first run, cache it per step, replay it deterministically on subsequent runs.

Which one you reach for depends on what you’re trying to do.

strategy.object() — parse input into params

The most common method. Use it whenever you need to turn natural language into structured API parameters — which is most of the time.

const params = await tools.strategy.object({
task: 'parse stripe query into relative period type',
schema: { period: '"today"|"week"|"month"', n: '?number' },
})
// first run: AI generates JS snippet, caches it
// every run after: cached snippet runs instantly
// -> { period: "week" }

The generated JS receives { instruction, summary, data } and returns an object matching the schema:

// generated for: "parse stripe query into relative period type"
const q = instruction.toLowerCase()
if (q.includes('today')) return { period: 'today' }
if (q.includes('week')) return { period: 'week' }
if (q.includes('month')) return { period: 'month' }
return { period: 'today' }

For simple constant extractions, the generated JS can be as trivial as:

return { period: 'week' }

Both work. The JS approach gives the LLM flexibility to generate smarter parsing when the input warrants it.

Options:

FieldTypeDescription
taskstringwhat the strategy needs to figure out
schemaobjectexpected output shape — keys are field names, values are type descriptions
modelOrTierModelOrTierAI model tier: 'xxs', 'sm', 'md', 'lg'
forceRegeneratebooleanregenerate even if a cached strategy exists

strategy.list() — extract arrays

Same mechanics as object() but the generated JS returns an array. Reach for this when you’re pulling multiple items out of input — ordered steps, tagged entities, parsed lists.

const steps = await tools.strategy.list({
task: 'break deployment plan into ordered steps',
schema: { name: 'string', command: 'string' },
})
// -> [{ name: "build", command: "npm run build" }, ...]

strategy.data() — map API responses

This is the heavy lifter for data-intensive apps. It generates a JS function that transforms raw API responses into typed AppResultDataItem objects the UI can render automatically — tables, lists, badges, links all work without per-app rendering code.

The really cool part: the app developer doesn’t write any mapping code. On first run, the AI sees a sample of the raw data and generates a complete mapping function.

const raw = await stripeAPI('/charges', params)
const result = await tools.strategy.data({
data: raw,
task: 'stripe payments',
fields: ['amount', 'customer_email', 'status'],
})
// -> { summary: "Found 25 payments...", items: AppResultDataItem[], count: 25, meta: { total: 3420 } }
return { summary: result.summary, data: result.items }

Options:

FieldTypeDescription
dataunknownraw API response data
taskstringwhat this data represents (e.g. “stripe payments”)
fieldsstring[]fields to prioritize in summary and mapping
modelOrTierModelOrTierAI model tier
forceRegeneratebooleanregenerate even if cached
maxRetriesnumberretry attempts if generated code fails (default: 2)

Return shape:

{
summary: string // human-readable summary
items: AppResultDataItem[] // typed data items the UI can render
count: number // always items.length
meta?: Record<string, unknown> // aggregates, computed fields
}

Each item conforms to AppResultDataItem — a shape with well-defined fields the UI knows how to render:

{
type: 'payment',
key: 'stripe-payment:ch_abc',
title: 'alice@co.com - $420',
status: 'succeeded',
kind: 'transaction',
url: 'https://dashboard.stripe.com/payments/ch_abc',
externalId: 'ch_abc',
value: { amount: 420, email: 'alice@co.com', currency: 'usd' },
}

Here’s what a generated JS snippet looks like for Stripe payments:

// generated once, cached:
const items = raw.data.map((c) => ({
type: 'payment',
key: `stripe-payment:${c.id}`,
title: `${c.billing_details?.name || c.customer} - $${(c.amount / 100).toFixed(2)}`,
status: c.status,
kind: 'transaction',
url: `https://dashboard.stripe.com/payments/${c.id}`,
externalId: c.id,
value: JSON.stringify({
amount: c.amount / 100,
currency: c.currency,
email: c.billing_details?.email,
}),
}))
// ... summary + meta generation

strategy.fetch() — params + request in one shot

A common pattern: use a strategy to figure out API parameters, then immediately fetch data with them. strategy.fetch() combines both steps so you don’t have to wire them together yourself.

const result = await tools.strategy.fetch({
task: `determine API path and query params for GitHub issues\nAvailable repos: ${options.repos}`,
schema: {
owner: 'string',
repo: 'string',
state: '"open"|"closed"|"all"',
},
fetch: (params) =>
githubFetch(`/repos/${params.owner}/${params.repo}/issues`, {
state: params.state,
}),
})

Internally it uses strategy.object() to resolve the parameters (cached), then calls your fetch function every run.

Schemas.Data

A preset schema matching AppResultDataItem, exported from start/app so apps can reference the standard data shape:

import { Schemas } from 'start/app'
// Schemas.Data = {
// type: 'string',
// key: '?string',
// value: 'string',
// title: '?string',
// status: '?string',
// kind: '?string',
// url: '?string',
// externalId: '?string',
// }

Self-healing

This is one of the genuinely exciting parts. Strategies don’t just cache — they recover from failures automatically at two levels.

Method-level recovery

Built into every strategy method. When cached code fails, the system doesn’t just throw — it iterates:

  1. Execute the cached JS snippet
  2. If it throws, regenerate with the error context AND the failed code
  3. Retry (up to 2 attempts by default, configurable for data())
  4. Validate return shape matches schema if provided
  5. Cache the working version

The key insight: on retry, the LLM sees both the error AND its own previous code. It can fix the specific bug instead of starting from scratch. An API changes its response shape? The strategy adapts on the next run.

The retry prompt looks like:

## previous attempt failed Code: ```js // the failed JS snippet in full

Error: TypeError: Cannot read property ‘amount’ of undefined

Fix the error. The data shape may differ from what you assumed.

### Runner-level recovery When a step fails and has a cached strategy, the flow runner kicks in: 1. Clear the strategy for that step 2. Store the error message 3. Retry the step (using existing retry mechanism) 4. On retry, strategy methods see no cache and regenerate fresh 5. Error context is available to the AI during regeneration This makes self-healing automatic for every app step that uses strategies, without apps doing anything special. The app developer writes their strategy calls, and the entire recovery pipeline comes for free. ## Time-independence > **The most important rule for strategies: they must be time-independent.** This deserves emphasis because it's the most common mistake when writing strategy-powered apps. A strategy is saved on first run and reused forever. If it contains a hardcoded timestamp, that timestamp is frozen. The strategy should capture the _shape_ of the operation -- relative types, field names, filter patterns -- and the app computes concrete values at runtime. ```ts // BAD -- cached ISO date becomes stale on next run const parsed = await tools.strategy.object({ task: `Parse: "today"\nCurrent time: ${new Date().toISOString()}`, schema: { created: { gte: 'unix_ts', lte: 'unix_ts' } }, }) // GOOD -- relative type cached, timestamps computed in app code const parsed = await tools.strategy.object({ task: `Parse: "today" into a relative period type`, schema: { relative: '"today"|"yesterday"|"last-n-days"', n: '?number' }, }) const range = resolveAtRuntime(parsed, new Date())

This pattern appears throughout the built-in apps. The Stripe app caches { period: "week" } and computes created.gte / created.lte from period at runtime. The GitHub app caches { since: "last-7-days" } and resolves the ISO date when the action runs.

Think of it this way: the strategy captures what to do. The app code handles when.

Key format hints

When an app has multiple data paths (sync, actions, webhooks), they should all write the same keys for the same entities. Otherwise three separate strategy runs might generate slightly different key formats for the same data. Pass keyFormats in hints to constrain the generated JS:

const result = await tools.strategy.data({
data: rawIssues,
task: 'github issues',
hints: {
keyFormats: {
issue: 'github-issue:{number}',
pr: 'github-pr:{number}',
},
},
})

Now /github issues (action), hourly sync, and webhook events all write to github-issue:42 for issue #42.

Key format hints are extracted from hints and surfaced as a hard constraint in the prompt. Other hints fields are passed as general context.

Invalidation

Strategies are cached per step index within a flow. They can be invalidated:

  • Per-call: pass forceRegenerate: true to regenerate
  • Per-step: call tools.strategy.clear() in app code
  • Per-flow: the flow UI has a button to clear all strategies

In practice, strategies rarely need manual invalidation. They capture the shape of the operation, not the data — so they stay correct as long as the flow’s structure doesn’t change. Self-healing handles most runtime failures automatically.