Skip to main content

Overview

Stepkit provides two ways to add conditional logic to pipelines:
  1. Step-level conditions: Skip individual steps based on context
  2. Branch-based routing: Execute different pipeline paths based on conditions

Step-Level Conditions

Use the condition configuration to conditionally execute a step:
condition
boolean | (context: TContext) => boolean | Promise<boolean>
If false or the function returns false, the step is skipped. Can be a boolean value or a function that receives the current context.

Static Condition

const pipeline = stepkit<{ debug: boolean }>()
  .step({ name: 'log-debug', condition: false }, () => {
    console.log('This never runs')
    return {}
  })

Dynamic Condition

const pipeline = stepkit<{ idea: string }>()
  .step('gather-market-signals', async ({ idea }) => ({
    marketSize: await fetchMarketSize(idea)
  }))
  .step(
    { 
      name: 'run-forecast', 
      condition: ({ marketSize }) => marketSize === 'large' 
    },
    async ({ idea }) => ({ forecast: await forecastROI(idea) }),
  )
  .step('evaluate', async ({ idea, marketSize, forecast }) => {
    // forecast is typed as optional because the step has a condition
    const { text } = await generateText({
      model: openai('gpt-4.1'),
      prompt: `Rate this idea: "${idea}"\nMarket: ${marketSize}\nForecast: ${forecast ?? 'n/a'}`,
    })
    return { evaluation: text }
  })
When a step has a condition, its outputs are typed as optional (Partial<T>) in downstream steps, since the step might be skipped.

Branch-Based Routing

The branchOn() method creates mutually exclusive execution paths based on conditions:
.branchOn(
  name?, // optional branch name
  ...cases // branch cases with when/then or default
)

Basic Branching

const pipeline = stepkit<{ content: string; userId: string }>()
  .step('classify-content', async ({ content }) => {
    const { text } = await generateText({
      model: openai('gpt-4.1'),
      prompt: `Classify content as safe, suspicious, or dangerous.\n\n${content}`,
    })
    return { riskLevel: text.trim().toLowerCase() as 'safe' | 'suspicious' | 'dangerous' }
  })
  .branchOn(
    'policy-route',
    {
      name: 'safe',
      when: ({ riskLevel }) => riskLevel === 'safe',
      then: (b) => b.step('publish', async () => ({ action: 'published' as const })),
    },
    {
      name: 'suspicious',
      when: ({ riskLevel }) => riskLevel === 'suspicious',
      then: (b) =>
        b
          .step('queue-review', async () => ({ 
            reviewTicketId: await createReviewTicket() 
          }))
          .step('notify-moderators', async ({ reviewTicketId }) => ({
            moderatorNotified: await notifyModerators(reviewTicketId),
          }))
          .step('hold', () => ({ action: 'held-for-review' as const })),
    },
    {
      name: 'dangerous',
      default: (b) =>
        b
          .step('block-user', async ({ userId }) => ({ 
            blocked: await blockUser(userId) 
          }))
          .step('send-user-email', async ({ blocked }) => ({
            userMessaged: blocked ? await sendUserEmail('Your content was blocked') : false,
          }))
          .step('notify-admin', async () => ({ adminNotified: await notifyAdmin() }))
          .step('finalize', () => ({ action: 'blocked' as const })),
    },
  )

Branch Cases

Each branch case can have:
name
string
Optional name for the branch case (used in logging and step names)
when
(context: TContext) => boolean | Promise<boolean>
Condition function that determines if this branch should execute
then
StepkitBuilder | (builder: StepkitBuilder) => StepkitBuilder
The pipeline to execute if the condition matches. Can be a pre-built pipeline or a builder function.
default
StepkitBuilder | (builder: StepkitBuilder) => StepkitBuilder
The default branch to execute if no other conditions match. Only one case can have default.

Branch Evaluation Order

  • Cases are evaluated in order
  • The first case where when() returns true is executed
  • If no conditions match, the default case is executed (if provided)
  • If no conditions match and there’s no default, the branch is skipped
const pipeline = stepkit<{ score: number }>()
  .branchOn(
    'route',
    {
      name: 'excellent',
      when: ({ score }) => score >= 90,
      then: (b) => b.step('reward', () => ({ badge: 'gold' })),
    },
    {
      name: 'good',
      when: ({ score }) => score >= 70, // only checked if score < 90
      then: (b) => b.step('reward', () => ({ badge: 'silver' })),
    },
    {
      name: 'needs-improvement',
      default: (b) => b.step('encourage', () => ({ badge: 'bronze' })),
    },
  )

Reusable Branch Pipelines

Define branch pipelines separately for reusability:
import { StepOutput } from 'stepkit'

// Classify input
const classify = stepkit<{ prompt: string }>()
  .step('classify', async ({ prompt }) => {
    const { text } = await generateText({
      model: openai('gpt-4.1'),
      prompt: `Is this a question or statement? One word.\n\n${prompt}`,
    })
    return { type: text.trim().toLowerCase() }
  })

// Extract type for reusable branches
type Classified = StepOutput<typeof classify, 'classify'>

// Reusable pipelines
const handleQuestion = stepkit<Classified>()
  .step('answer', async ({ prompt }) => {
    const { text } = await generateText({
      model: openai('gpt-4.1'),
      prompt: `Answer: ${prompt}`,
    })
    return { response: text }
  })

const handleStatement = stepkit<Classified>()
  .step('acknowledge', () => ({ response: 'Thanks for sharing!' }))

// Compose with full type safety
const responder = classify
  .branchOn(
    'route',
    {
      name: 'question',
      when: ({ type }) => type === 'question',
      then: handleQuestion,
    },
    { 
      name: 'statement', 
      default: handleStatement 
    },
  )
  .step('finalize', ({ response }) => ({ done: true, response }))

await responder.run({ prompt: 'What is AI?' })

Branch Configuration

Branches support the same configuration options as regular steps:
const pipeline = stepkit<{ value: number }>()
  .branchOn(
    {
      name: 'route',
      timeout: 5000,
      onError: 'continue',
      log: true,
    },
    {
      when: ({ value }) => value > 0,
      then: (b) => b.step('process-positive', () => ({ result: 'positive' })),
    },
    {
      default: (b) => b.step('process-negative', () => ({ result: 'negative' })),
    },
  )

Type Safety

Branch outputs are properly typed. Since only one branch executes, outputs from all branches are typed as optional:
const pipeline = stepkit<{ type: 'a' | 'b' }>()
  .branchOn(
    'route',
    {
      name: 'type-a',
      when: ({ type }) => type === 'a',
      then: (b) => b.step('handle-a', () => ({ resultA: 'A' })),
    },
    {
      name: 'type-b',
      when: ({ type }) => type === 'b',
      then: (b) => b.step('handle-b', () => ({ resultB: 'B' })),
    },
  )
  .step('use-result', ({ resultA, resultB }) => {
    // Both are typed as optional: string | undefined
    return { result: resultA ?? resultB ?? 'unknown' }
  })

Nested Branches

You can nest branches within branches:
const pipeline = stepkit<{ category: string; priority: string }>()
  .branchOn(
    'category-route',
    {
      name: 'important',
      when: ({ category }) => category === 'important',
      then: (b) =>
        b.branchOn(
          'priority-route',
          {
            name: 'urgent',
            when: ({ priority }) => priority === 'urgent',
            then: (bb) => bb.step('urgent-handler', () => ({ handled: 'urgent-important' })),
          },
          {
            name: 'normal',
            default: (bb) => bb.step('normal-handler', () => ({ handled: 'normal-important' })),
          },
        ),
    },
    {
      name: 'routine',
      default: (b) => b.step('routine-handler', () => ({ handled: 'routine' })),
    },
  )

Logging

When logging is enabled, branch execution is clearly indicated:
await pipeline.run({ type: 'question' }, { log: { stopwatch: true } })
Output:
🔀 Branch: route
   ↳ Executing: question

📍 Step: route/question/answer
✅ route/question/answer completed in 250ms
   Output: response

Build docs developers (and LLMs) love