diff --git a/pkgs/website/astro.config.mjs b/pkgs/website/astro.config.mjs
index 8f85d3bf7..edf63aaf3 100644
--- a/pkgs/website/astro.config.mjs
+++ b/pkgs/website/astro.config.mjs
@@ -253,6 +253,10 @@ export default defineConfig({
label: 'Retrying steps',
link: '/build/retrying-steps/',
},
+ {
+ label: 'Graceful Failure',
+ link: '/build/graceful-failure/',
+ },
{
label: 'Validation steps',
link: '/build/validation-steps/',
@@ -271,6 +275,10 @@ export default defineConfig({
},
],
},
+ {
+ label: 'Conditional Steps',
+ autogenerate: { directory: 'build/conditional-steps/' },
+ },
{
label: 'Starting Flows',
autogenerate: { directory: 'build/starting-flows/' },
diff --git a/pkgs/website/src/assets/pgflow-theme.d2 b/pkgs/website/src/assets/pgflow-theme.d2
index c4794e52b..65fc375b8 100644
--- a/pkgs/website/src/assets/pgflow-theme.d2
+++ b/pkgs/website/src/assets/pgflow-theme.d2
@@ -69,7 +69,7 @@ classes: {
style.stroke: "#e85c5c"
}
- # Step state classes (created, started, completed, failed)
+ # Step state classes (created, started, completed, failed, skipped)
step_created: {
style.fill: "#95a0a3"
style.stroke: "#4a5759"
@@ -86,6 +86,11 @@ classes: {
style.fill: "#a33636"
style.stroke: "#e85c5c"
}
+ step_skipped: {
+ style.fill: "#4a5759"
+ style.stroke: "#6b7a7d"
+ style.stroke-dash: 3
+ }
# Task state classes (queued, completed, failed)
task_queued: {
diff --git a/pkgs/website/src/content/docs/build/conditional-steps/examples.mdx b/pkgs/website/src/content/docs/build/conditional-steps/examples.mdx
new file mode 100644
index 000000000..71a931f4e
--- /dev/null
+++ b/pkgs/website/src/content/docs/build/conditional-steps/examples.mdx
@@ -0,0 +1,304 @@
+---
+title: Examples
+description: AI/LLM workflow patterns using conditional execution.
+sidebar:
+ order: 4
+---
+
+import { Aside } from '@astrojs/starlight/components';
+
+This page shows AI/LLM workflow patterns that benefit from conditional execution. Each example includes a diagram and condensed flow code.
+
+
+
+## Query Routing
+
+Route to different handlers based on input. Simple questions go to a fast model, complex reasoning to a powerful model, and code questions to a code-specialized model.
+
+```d2 width="700" pad="20"
+...@../../../../assets/pgflow-theme.d2
+
+direction: right
+
+input: "Query" { class: neutral }
+classify: "Classify" { class: step_completed }
+simple: "Simple" { class: step_skipped }
+complex: "Complex" { class: step_skipped }
+code: "Code" { class: step_started }
+respond: "Respond" { class: step_created }
+
+input -> classify
+classify -> simple { style.stroke-dash: 3 }
+classify -> complex { style.stroke-dash: 3 }
+classify -> code: "intent=code"
+simple -> respond { style.stroke-dash: 3 }
+complex -> respond { style.stroke-dash: 3 }
+code -> respond
+```
+
+```typescript
+new Flow<{ query: string }>({ slug: 'query_router' })
+ .step({ slug: 'classify' }, (flowInput) => classifyIntent(flowInput.query))
+ .step(
+ {
+ slug: 'simple',
+ dependsOn: ['classify'],
+ if: { classify: { intent: 'simple' } },
+ whenUnmet: 'skip',
+ },
+ async (_, ctx) => callFastModel((await ctx.flowInput).query)
+ )
+ .step(
+ {
+ slug: 'complex',
+ dependsOn: ['classify'],
+ if: { classify: { intent: 'complex' } },
+ whenUnmet: 'skip',
+ },
+ async (_, ctx) => callReasoningModel((await ctx.flowInput).query)
+ )
+ .step(
+ {
+ slug: 'code',
+ dependsOn: ['classify'],
+ if: { classify: { intent: 'code' } },
+ whenUnmet: 'skip',
+ },
+ async (_, ctx) => callCodeModel((await ctx.flowInput).query)
+ )
+ .step(
+ {
+ slug: 'respond',
+ dependsOn: ['simple', 'complex', 'code'],
+ },
+ (deps) => format(deps.simple ?? deps.complex ?? deps.code)
+ );
+```
+
+**Key points:**
+
+- Intent classification determines which model handles the query
+- Only ONE model runs per query - others are skipped
+- `respond` uses `??` to coalesce the single defined output
+
+---
+
+## Conditional Fallback
+
+Enrich only when the primary source is insufficient. If retrieval returns low-confidence results, fall back to web search for current information.
+
+```d2 width="600" pad="20"
+...@../../../../assets/pgflow-theme.d2
+
+direction: right
+
+query: "Query" { class: neutral }
+retrieve: "Retrieve" { class: step_completed }
+web: "Web Search" { class: step_started }
+generate: "Generate" { class: step_created }
+
+query -> retrieve
+retrieve -> web: "low confidence"
+retrieve -> generate
+web -> generate
+```
+
+```typescript
+new Flow<{ query: string }>({ slug: 'rag_fallback' })
+ .step({ slug: 'retrieve' }, (flowInput) => vectorSearch(flowInput.query)) // embedding happens inside
+ .step(
+ {
+ slug: 'web',
+ dependsOn: ['retrieve'],
+ if: { retrieve: { confidence: 'low' } },
+ whenUnmet: 'skip',
+ retriesExhausted: 'skip', // Continue if web search fails
+ },
+ async (_, ctx) => searchWeb((await ctx.flowInput).query)
+ )
+ .step(
+ {
+ slug: 'generate',
+ dependsOn: ['retrieve', 'web'],
+ },
+ async (deps, ctx) => {
+ const docs = [...deps.retrieve.docs, ...(deps.web ?? [])];
+ return generateAnswer((await ctx.flowInput).query, docs);
+ }
+ );
+```
+
+
+
+**Key points:**
+
+- Retrieval always runs first to check knowledge base
+- Web search is conditional on low confidence scores
+- `retriesExhausted: 'skip'` ensures graceful degradation if web search fails
+
+---
+
+## Graceful Failure Handling
+
+Continue execution when steps fail. Search multiple sources in parallel - if any source fails, continue with the others.
+
+```d2 width="700" pad="20"
+...@../../../../assets/pgflow-theme.d2
+
+direction: right
+
+query: "Query" { class: neutral }
+embed: "Embed" { class: step_completed }
+vector: "Vector" { class: step_completed }
+keyword: "Keyword" { class: step_completed }
+graph: "Graph" { class: step_skipped }
+rerank: "Rerank" { class: step_started }
+
+query -> embed
+embed -> vector
+embed -> keyword
+embed -> graph { style.stroke-dash: 3 }
+vector -> rerank
+keyword -> rerank
+graph -> rerank { style.stroke-dash: 3 }
+```
+
+```typescript
+new Flow<{ query: string }>({ slug: 'multi_retrieval' })
+ .step({ slug: 'embed' }, (flowInput) => createEmbedding(flowInput.query))
+ .step(
+ {
+ slug: 'vector',
+ dependsOn: ['embed'],
+ retriesExhausted: 'skip',
+ },
+ (deps) => searchPinecone(deps.embed.vector)
+ )
+ .step(
+ {
+ slug: 'keyword',
+ dependsOn: ['embed'],
+ retriesExhausted: 'skip',
+ },
+ async (_, ctx) => searchElastic((await ctx.flowInput).query)
+ )
+ .step(
+ {
+ slug: 'graph',
+ dependsOn: ['embed'],
+ retriesExhausted: 'skip',
+ },
+ async (_, ctx) => searchNeo4j((await ctx.flowInput).query)
+ )
+ .step(
+ {
+ slug: 'rerank',
+ dependsOn: ['vector', 'keyword', 'graph'],
+ },
+ async (deps, ctx) => {
+ const all = [
+ ...(deps.vector ?? []),
+ ...(deps.keyword ?? []),
+ ...(deps.graph ?? []),
+ ];
+ return rerankResults((await ctx.flowInput).query, all);
+ }
+ );
+```
+
+**Key points:**
+
+- Three retrieval sources run **in parallel** after embedding
+- Each source has `retriesExhausted: 'skip'` for resilience
+- `rerank` combines available results - handles undefined sources gracefully
+
+---
+
+## Layered Conditions
+
+Combine `skip` and `skip-cascade` for nested conditionals. If tool use is needed, validate with guardrails before execution. Skip the entire tool branch if no tool is needed.
+
+```d2 width="650" pad="20"
+...@../../../../assets/pgflow-theme.d2
+
+direction: right
+
+input: "Message" { class: neutral }
+plan: "Plan" { class: step_completed }
+validate: "Guardrails" { class: step_completed }
+execute: "Execute" { class: step_started }
+respond: "Respond" { class: step_created }
+
+input -> plan
+plan -> validate: "needsTool"
+plan -> respond
+validate -> execute: "approved"
+validate -> respond { style.stroke-dash: 3 }
+execute -> respond
+```
+
+```typescript
+new Flow<{ message: string }>({ slug: 'agent_guardrails' })
+ .step({ slug: 'plan' }, (flowInput) => planAction(flowInput.message))
+ .step(
+ {
+ slug: 'validate',
+ dependsOn: ['plan'],
+ if: { plan: { needsTool: true } },
+ whenUnmet: 'skip-cascade', // No tool needed = skip validation AND execution
+ },
+ (deps) => validateWithGuardrails(deps.plan.toolName, deps.plan.toolArgs)
+ )
+ .step(
+ {
+ slug: 'execute',
+ dependsOn: ['plan', 'validate'],
+ if: { validate: { approved: true } },
+ whenUnmet: 'skip', // Rejected = skip execution, still respond
+ },
+ (deps) => executeTool(deps.plan.toolName!, deps.plan.toolArgs!)
+ )
+ .step(
+ {
+ slug: 'respond',
+ dependsOn: ['plan', 'execute'],
+ },
+ async (deps, ctx) =>
+ generateResponse((await ctx.flowInput).message, deps.execute)
+ );
+```
+
+
+
+**Key points:**
+
+- `skip-cascade` on validation skips the entire tool branch when no tool is needed
+- `skip` on execution allows responding even when guardrails reject
+- Layered conditions: tool needed → guardrails approved → execute
+
+---
+
+## Pattern Comparison
+
+| Pattern | Use Case | Skip Mode | Output Type |
+| -------------------- | --------------------------- | -------------- | ------------------------ |
+| Query Routing | Mutually exclusive branches | `skip` | `T` or `undefined` |
+| Conditional Fallback | Enrich only when needed | `skip` | `T` or `undefined` |
+| Graceful Failure | Continue when steps fail | `skip` | `T` or `undefined` |
+| Layered Conditions | Nested skip + skip-cascade | `skip-cascade` | `T` (guaranteed if runs) |
+
+
diff --git a/pkgs/website/src/content/docs/build/conditional-steps/index.mdx b/pkgs/website/src/content/docs/build/conditional-steps/index.mdx
new file mode 100644
index 000000000..8042bb158
--- /dev/null
+++ b/pkgs/website/src/content/docs/build/conditional-steps/index.mdx
@@ -0,0 +1,129 @@
+---
+title: Conditional Steps
+description: Control which steps execute based on input patterns and handle failures gracefully.
+sidebar:
+ label: Overview
+ order: 0
+---
+
+import { Aside, CardGrid, LinkCard } from '@astrojs/starlight/components';
+
+pgflow supports conditional step execution based on input data patterns and configurable failure handling. This enables dynamic workflows that adapt to runtime conditions.
+
+## Two Systems, One Goal
+
+pgflow provides two orthogonal systems for controlling step execution:
+
+| System | When Evaluated | Purpose |
+| ----------------------------------------- | ---------------- | ----------------------------------------------------- |
+| **Pattern Matching** (`if`/`ifNot`) | Before step runs | Skip steps based on input values |
+| **Failure Handling** (`retriesExhausted`) | After step fails | Handle failures gracefully instead of failing the run |
+
+Both systems share the same three behavior modes: `fail`, `skip`, and `skip-cascade`.
+
+
+
+## Behavior Modes
+
+When a condition is unmet or a step fails, you control what happens:
+
+| Mode | Behavior |
+| -------------- | ----------------------------------------------------------------------------------------------- |
+| `fail` | Step fails, entire run fails (default for `retriesExhausted`) |
+| `skip` | Step marked as skipped, run continues, dependents receive `undefined` (default for `whenUnmet`) |
+| `skip-cascade` | Step AND all downstream dependents skipped, run continues |
+
+## Quick Examples
+
+### Conditional Execution
+
+Run premium-only features based on input:
+
+```typescript
+new Flow<{ userId: string; plan: 'free' | 'premium' }>({
+ slug: 'userOnboarding',
+})
+ .step({ slug: 'createAccount' }, async (input) => {
+ return { accountId: await createUser(input.run.userId) };
+ })
+ .step(
+ {
+ slug: 'setupPremiumFeatures',
+ dependsOn: ['createAccount'],
+ if: { plan: 'premium' }, // Only run for premium users
+ whenUnmet: 'skip', // Skip (don't fail) for free users
+ },
+ async (input) => {
+ return await enablePremium(input.createAccount.accountId);
+ }
+ );
+```
+
+### Graceful Failure Handling
+
+Continue the workflow even if an optional step fails:
+
+```typescript
+.step({
+ slug: 'sendWelcomeEmail',
+ dependsOn: ['createAccount'],
+ maxAttempts: 3,
+ retriesExhausted: 'skip', // If email fails, continue anyway
+}, async (input) => {
+ return await sendEmail(input.createAccount.accountId);
+})
+```
+
+
+
+## Type Safety
+
+pgflow's type system tracks which steps may be skipped:
+
+- **`skip` mode**: Dependent steps receive `T | undefined` - you must handle the missing case
+- **`skip-cascade` mode**: Dependents are also skipped, so if they run, output is guaranteed
+
+```typescript
+.step({
+ slug: 'processResults',
+ dependsOn: ['optionalEnrichment'],
+}, async (input) => {
+ // TypeScript knows this may be undefined
+ if (input.optionalEnrichment) {
+ return processWithEnrichment(input.optionalEnrichment);
+ }
+ return processBasic(input.run);
+})
+```
+
+## Learn More
+
+
+
+
+
+
+
diff --git a/pkgs/website/src/content/docs/build/conditional-steps/pattern-matching.mdx b/pkgs/website/src/content/docs/build/conditional-steps/pattern-matching.mdx
new file mode 100644
index 000000000..e6b2d418f
--- /dev/null
+++ b/pkgs/website/src/content/docs/build/conditional-steps/pattern-matching.mdx
@@ -0,0 +1,235 @@
+---
+title: Pattern Matching
+description: Use if/ifNot conditions to control step execution based on input patterns.
+sidebar:
+ order: 1
+---
+
+import { Aside, Code } from '@astrojs/starlight/components';
+
+Pattern matching lets you conditionally execute steps based on input data. pgflow uses PostgreSQL's `@>` JSON containment operator for pattern matching.
+
+## Basic Syntax
+
+Use `if` to run a step only when input contains a pattern:
+
+```typescript
+.step({
+ slug: 'premiumFeature',
+ if: { plan: 'premium' }, // Run if input contains plan: 'premium'
+ whenUnmet: 'skip', // Skip if condition not met
+}, handler)
+```
+
+Use `ifNot` to run a step only when input does NOT contain a pattern:
+
+```typescript
+.step({
+ slug: 'trialReminder',
+ ifNot: { plan: 'premium' }, // Run if input does NOT contain plan: 'premium'
+ whenUnmet: 'skip',
+}, handler)
+```
+
+
+
+## What Gets Checked
+
+The pattern is checked against different data depending on the step type:
+
+| Step Type | Pattern Checked Against |
+| -------------------------- | ------------------------------------------------------------ |
+| Root step (no `dependsOn`) | Flow input |
+| Dependent step | Aggregated dependency outputs: `{ depSlug: depOutput, ... }` |
+
+### Root Step Example
+
+For root steps, the pattern matches against the flow input:
+
+```typescript
+type Input = { userId: string; plan: 'free' | 'premium' };
+
+new Flow({ slug: 'onboarding' }).step(
+ {
+ slug: 'setupPremium',
+ if: { plan: 'premium' }, // Checks flow input.plan
+ whenUnmet: 'skip',
+ },
+ async (input) => {
+ // input.run.plan is guaranteed to be 'premium' here
+ return await enablePremiumFeatures(input.run.userId);
+ }
+);
+```
+
+### Dependent Step Example
+
+For dependent steps, the pattern matches against an object containing all dependency outputs:
+
+```typescript
+new Flow<{ url: string }>({ slug: 'contentPipeline' })
+ .step({ slug: 'analyze' }, async (input) => {
+ const result = await analyzeContent(input.run.url);
+ return { needsModeration: result.flagged, content: result.text };
+ })
+ .step(
+ {
+ slug: 'moderate',
+ dependsOn: ['analyze'],
+ if: { analyze: { needsModeration: true } }, // Check analyze output
+ whenUnmet: 'skip',
+ },
+ async (input) => {
+ return await moderateContent(input.analyze.content);
+ }
+ );
+```
+
+The pattern `{ analyze: { needsModeration: true } }` matches the object `{ analyze: