Skip to main content

Overview

Steps are the fundamental building blocks of Motia applications. Each step is a self-contained unit of work that responds to triggers and can enqueue events for other steps. Steps are defined by two key components: a config and a handler.

Anatomy of a Step

Every step file (.step.ts or .step.py) exports two main elements:

Config

The config defines metadata and behavior:
  • name: Unique identifier for the step
  • description: Human-readable description
  • triggers: Array of trigger configurations (HTTP, queue, cron, state, stream)
  • enqueues: Topics this step can publish to
  • flows: Flow names this step belongs to
  • infrastructure: Resource limits (RAM, CPU, timeout)

Handler

The handler is an async function that processes inputs and returns responses. It receives:
  1. input: The trigger payload (varies by trigger type)
  2. ctx: Flow context with utilities for enqueuing, logging, state management, and streams

Basic Example

Here’s a simple step that receives HTTP requests and enqueues events:
import type { Handlers, StepConfig } from 'motia'
import { z } from 'zod'

export const config = {
  name: 'HelloAPI',
  description: 'Receives hello request and enqueues event for processing',
  triggers: [
    {
      type: 'http',
      path: '/hello',
      method: 'GET',
      responseSchema: {
        200: z.object({
          message: z.string(),
          status: z.string(),
          appName: z.string(),
        }),
      },
    },
  ],
  enqueues: ['process-greeting'],
  flows: ['hello-world-flow'],
} as const satisfies StepConfig

export const handler: Handlers<typeof config> = async (_, { enqueue, logger }) => {
  const appName = 'III App'
  const timestamp = new Date().toISOString()

  logger.info('Hello API endpoint called', { appName, timestamp })

  await enqueue({
    topic: 'process-greeting',
    data: {
      timestamp,
      appName,
      greetingPrefix: process.env.GREETING_PREFIX || 'Hello',
      requestId: Math.random().toString(36).substring(7),
    },
  })

  return {
    status: 200,
    body: {
      message: 'Hello request received! Check logs for processing.',
      status: 'processing',
      appName,
    },
  }
}

Step Discovery

Motia automatically discovers steps in your project:
  • TypeScript/JavaScript: Files matching **/*.step.{ts,js}
  • Python: Files matching **/*.step.py
Excluded directories: node_modules/, dist/, __pycache__/ Each step is assigned a unique ID using UUID v5 based on its file path:
const STEP_NAMESPACE = '7f1c3ff2-9b00-4d0a-bdd7-efb8bca49d4f'
const stepId = uuidv5(filePath, STEP_NAMESPACE)
Reference: motia-js/packages/motia/src/new/build/loader.ts:30

Working with Multiple Triggers

Steps can respond to multiple trigger types. Use ctx.match() to handle each trigger differently:
import type { Handlers, StepConfig } from 'motia'
import { z } from 'zod'

export const config = {
  name: 'MultiTriggerExample',
  description: 'Processes orders via event, API, or cron',
  flows: ['multi-trigger-demo'],
  triggers: [
    {
      type: 'queue',
      topic: 'order.created',
      input: z.object({
        amount: z.number(),
        description: z.string(),
      }),
    },
    {
      type: 'http',
      method: 'POST',
      path: '/orders/manual',
      bodySchema: z.object({
        user: z.object({ verified: z.boolean() }),
        amount: z.number(),
        description: z.string(),
      }),
      responseSchema: {
        200: z.object({
          message: z.string(),
          orderId: z.string(),
        }),
      },
    },
    {
      type: 'cron',
      expression: '* * * * *',
    },
  ],
  enqueues: ['order.processed'],
} as const satisfies StepConfig

export const handler: Handlers<typeof config> = async (_, ctx): Promise<any> => {
  const orderId = `order-${Date.now()}-${Math.random().toString(36).substring(7)}`

  return ctx.match({
    http: async ({ request }) => {
      ctx.logger.info('Processing manual order via API', {
        amount: request.body.amount,
      })

      await ctx.state.set('orders', orderId, {
        id: orderId,
        amount: request.body.amount,
        description: request.body.description,
        source: 'manual-api',
        createdAt: new Date().toISOString(),
      })

      await ctx.enqueue({
        topic: 'order.processed',
        data: { orderId, amount: request.body.amount, source: 'manual-api' },
      })

      return {
        status: 200,
        body: { message: 'Order processed successfully', orderId },
      }
    },

    queue: async (queueInput) => {
      ctx.logger.info('Processing order from queue', queueInput)

      await ctx.state.set('orders', orderId, {
        id: orderId,
        amount: queueInput.amount,
        description: queueInput.description,
        source: 'event',
        createdAt: new Date().toISOString(),
      })

      await ctx.enqueue({
        topic: 'order.processed',
        data: { orderId, amount: queueInput.amount, source: 'event' },
      })
    },

    cron: async () => {
      ctx.logger.info('Processing scheduled order batch')

      const pendingOrders = await ctx.state.list<{ id: string; amount: number }>('pending-orders')

      for (const order of pendingOrders) {
        await ctx.enqueue({
          topic: 'order.processed',
          data: { orderId: order.id, amount: order.amount, source: 'cron-batch' },
        })
      }
    },
  })
}
Reference: motia-js/playground/steps/multi-trigger-example.step.ts

Schema Validation

Motia supports both Zod and JSON Schema for input validation:
import { z } from 'zod'
import { http } from 'motia'

// Using Zod
const todoSchema = z.object({
  id: z.string(),
  description: z.string(),
  createdAt: z.string(),
  dueDate: z.string().optional(),
})

export const config = {
  name: 'CreateTodo',
  triggers: [
    http('POST', '/todo', {
      bodySchema: z.object({ 
        description: z.string(), 
        dueDate: z.string().optional() 
      }),
      responseSchema: {
        200: todoSchema,
        400: z.object({ error: z.string() }),
      },
    }),
  ],
}

Infrastructure Configuration

Configure resource limits per step:
export const config = {
  name: 'HeavyProcessing',
  triggers: [
    queue('heavy-task', {
      infrastructure: {
        handler: {
          ram: 512,    // MB
          cpu: 2,      // vCPUs
          timeout: 300 // seconds
        },
        queue: {
          type: 'fifo',
          maxRetries: 5,
          visibilityTimeout: 60,
          delaySeconds: 0,
        },
      },
    }),
  ],
}
Reference: motia-js/packages/motia/src/types.ts:147-163

Type Safety

Motia provides end-to-end type safety:
// Config is strongly typed
export const config = {
  name: 'CreateTodo',
  triggers: [http('POST', '/todo', { bodySchema })],
  enqueues: ['todo-created'],
} as const satisfies StepConfig

// Handler input types are inferred from config
export const handler: Handlers<typeof config> = async ({ request }, ctx) => {
  // request.body is typed based on bodySchema
  const { description, dueDate } = request.body
  
  // ctx.enqueue is typed based on enqueues array
  await ctx.enqueue({
    topic: 'todo-created', // Type error if topic doesn't match config
    data: { todoId: '123' },
  })
}

Best Practices

Each step should have a single responsibility. Break complex workflows into multiple steps connected by events.
Step names should clearly indicate what they do: CreateTodo, ProcessOrder, SendNotification.
Use as const satisfies StepConfig to ensure config correctness and get proper type inference in handlers.
Return appropriate HTTP status codes for API triggers and use try-catch for async operations.
Use virtualEnqueues when a step conditionally enqueues events, allowing the flow graph to remain accurate.

Next Steps

Triggers

Learn about the 5 trigger types

Context

Explore the Flow Context API

State Management

Work with persistent state

Workflows

Organize steps into workflows

Build docs developers (and LLMs) love