Skip to main content
This tutorial walks you through creating and understanding Steps - the core primitive of Motia. By the end, you’ll understand how to build HTTP endpoints, queue processors, cron jobs, and more using the same pattern.

What is a Step?

A Step is a file with two exports:
  1. config - Defines the Step’s name, triggers, and behavior
  2. handler - The function that executes when the Step is triggered
Motia automatically discovers Steps in your steps/ directory and connects them to the iii engine.
Steps must have the .step.ts, .step.js, or _step.py suffix to be auto-discovered.

Step Anatomy

Let’s break down a simple Step:
// steps/greet.step.ts
import type { Handlers, StepConfig } from 'motia'
import { z } from 'zod'

// 1. Configuration
export const config = {
  name: 'GreetUser',
  description: 'Greets a user and stores the greeting',
  triggers: [
    {
      type: 'http',
      method: 'GET',
      path: '/greet',
      responseSchema: {
        200: z.object({
          message: z.string(),
          timestamp: z.string(),
        }),
      },
    },
  ],
  enqueues: ['greeting.created'],
} as const satisfies StepConfig

// 2. Handler
export const handler: Handlers<typeof config> = async (
  request,
  { logger, enqueue, state }
) => {
  // Extract query parameter
  const name = request.query?.name || 'World'
  
  // Log the action
  logger.info('Greeting user', { name })
  
  // Create greeting
  const greeting = {
    message: `Hello, ${name}!`,
    timestamp: new Date().toISOString(),
  }
  
  // Store in state
  await state.set('greetings', name, greeting)
  
  // Enqueue for background processing
  await enqueue({
    topic: 'greeting.created',
    data: greeting,
  })
  
  // Return HTTP response
  return {
    status: 200,
    body: greeting,
  }
}

The Config Object

The config object defines your Step’s metadata and behavior:
PropertyTypeDescription
namestringUnique identifier for the Step
descriptionstring (optional)Human-readable description
triggersarrayHow the Step is invoked
enqueuesarray (optional)Queue topics this Step can publish to
flowsarray (optional)Logical groupings for organization

Triggers

Triggers determine when and how your Step runs. Here are the main types:
Triggers on HTTP requests:
triggers: [
  {
    type: 'http',
    method: 'GET' | 'POST' | 'PUT' | 'DELETE' | 'PATCH',
    path: '/users/:id',  // Supports path parameters
    bodySchema: z.object({ ... }),  // Optional request validation
    responseSchema: {
      200: z.object({ ... }),  // Type-safe responses
      404: z.object({ error: z.string() }),
    },
  },
]
Use cases: REST APIs, webhooks, form submissions

Multiple Triggers

Steps can have multiple triggers:
triggers: [
  { type: 'http', method: 'POST', path: '/process' },
  { type: 'queue', topic: 'process.requested' },
  { type: 'cron', schedule: '0 */6 * * *' },  // Every 6 hours
]
This Step runs via HTTP, queue messages, OR cron schedule.

The Handler Function

The handler receives two arguments:

1. Input (First Argument)

The input type depends on the trigger:
handler: async (request, ctx) => {
  request.body      // Parsed request body
  request.query     // Query parameters
  request.params    // Path parameters
  request.headers   // Request headers
  request.method    // HTTP method
  request.path      // Request path
}

2. Context (Second Argument)

The context provides APIs for interacting with the Motia system:
PropertyTypeDescription
loggerLoggerStructured logging
enqueueFunctionPublish to queues
stateStateKey-value state storage
streamsStreamsReal-time data streams
traceIdstringDistributed tracing ID
invokeFunctionCall other Steps directly

Logger

ctx.logger.info('User created', { userId: '123' })
ctx.logger.warn('Rate limit approaching', { count: 95 })
ctx.logger.error('Payment failed', { error: err.message })

Enqueue

await ctx.enqueue({
  topic: 'email.send',
  data: {
    to: '[email protected]',
    subject: 'Welcome!',
    body: 'Thanks for signing up',
  },
})

State

// Set state
await ctx.state.set('users', userId, { name, email, status: 'active' })

// Get state
const user = await ctx.state.get('users', userId)

// Delete state
await ctx.state.delete('users', userId)

// List items in a group
const allUsers = await ctx.state.list('users')

Streams

// Update a stream
await ctx.streams.todos.set(groupId, itemId, {
  id: itemId,
  title: 'Buy groceries',
  completed: false,
})

// Patch updates
await ctx.streams.todos.update(groupId, itemId, [
  { type: 'set', path: 'completed', value: true },
])

Complete Example: Todo API

Let’s build a complete todo API with create, list, and process Steps:
1

Create Todo Step

import type { Handlers, StepConfig } from 'motia'
import { z } from 'zod'

const todoSchema = z.object({
  title: z.string().min(1),
  description: z.string().optional(),
})

export const config = {
  name: 'CreateTodo',
  triggers: [
    {
      type: 'http',
      method: 'POST',
      path: '/todos',
      bodySchema: todoSchema,
      responseSchema: {
        201: z.object({
          id: z.string(),
          title: z.string(),
          description: z.string().optional(),
          completed: z.boolean(),
          createdAt: z.string(),
        }),
      },
    },
  ],
  enqueues: ['todo.created'],
} as const satisfies StepConfig

export const handler: Handlers<typeof config> = async (
  request,
  { logger, state, enqueue }
) => {
  const { title, description } = request.body
  const id = crypto.randomUUID()
  
  const todo = {
    id,
    title,
    description: description || '',
    completed: false,
    createdAt: new Date().toISOString(),
  }
  
  // Store in state
  await state.set('todos', id, todo)
  
  // Enqueue for background processing
  await enqueue({
    topic: 'todo.created',
    data: { todoId: id },
  })
  
  logger.info('Todo created', { id, title })
  
  return { status: 201, body: todo }
}
2

List Todos Step

import type { Handlers, StepConfig } from 'motia'
import { z } from 'zod'

export const config = {
  name: 'ListTodos',
  triggers: [
    {
      type: 'http',
      method: 'GET',
      path: '/todos',
      responseSchema: {
        200: z.object({
          todos: z.array(z.any()),
          count: z.number(),
        }),
      },
    },
  ],
} as const satisfies StepConfig

export const handler: Handlers<typeof config> = async (
  _,
  { logger, state }
) => {
  // Get all todos from state
  const todos = await state.list('todos')
  
  logger.info('Listed todos', { count: todos.length })
  
  return {
    status: 200,
    body: {
      todos,
      count: todos.length,
    },
  }
}
3

Process Todo Step (Background)

import type { Handlers, StepConfig } from 'motia'
import { z } from 'zod'

const inputSchema = z.object({
  todoId: z.string(),
})

export const config = {
  name: 'ProcessTodo',
  description: 'Background processing for new todos',
  triggers: [
    {
      type: 'queue',
      topic: 'todo.created',
      input: inputSchema,
    },
  ],
} as const satisfies StepConfig

export const handler: Handlers<typeof config> = async (
  input,
  { logger, state }
) => {
  const { todoId } = input
  
  // Get todo from state
  const todo = await state.get('todos', todoId)
  
  if (!todo) {
    logger.warn('Todo not found', { todoId })
    return
  }
  
  logger.info('Processing todo', { todoId, title: todo.title })
  
  // Simulate processing
  await new Promise(resolve => setTimeout(resolve, 2000))
  
  logger.info('Todo processed', { todoId })
}

Testing Your Steps

With the iii engine running, test your Steps:
# Create a todo
curl -X POST http://localhost:3111/todos \
  -H "Content-Type: application/json" \
  -d '{"title": "Learn Motia", "description": "Complete the tutorial"}'

# Response:
{
  "id": "550e8400-e29b-41d4-a716-446655440000",
  "title": "Learn Motia",
  "description": "Complete the tutorial",
  "completed": false,
  "createdAt": "2026-03-04T10:30:00.000Z"
}

# List todos
curl http://localhost:3111/todos

# Response:
{
  "todos": [
    {
      "id": "550e8400-e29b-41d4-a716-446655440000",
      "title": "Learn Motia",
      "description": "Complete the tutorial",
      "completed": false,
      "createdAt": "2026-03-04T10:30:00.000Z"
    }
  ],
  "count": 1
}
Check your console logs to see the background processing:
[ProcessTodo] Processing todo: 550e8400-e29b-41d4-a716-446655440000
[ProcessTodo] Todo processed: 550e8400-e29b-41d4-a716-446655440000

Advanced Patterns

Conditional Triggers

triggers: [
  {
    type: 'queue',
    topic: 'order.created',
    condition: (input) => input.amount > 100,  // Only process large orders
  },
]

Error Handling

export const handler: Handlers<typeof config> = async (input, ctx) => {
  try {
    await processOrder(input)
  } catch (error) {
    ctx.logger.error('Order processing failed', {
      error: error.message,
      orderId: input.orderId,
    })
    
    // Enqueue for retry or dead letter queue
    await ctx.enqueue({
      topic: 'order.failed',
      data: { orderId: input.orderId, error: error.message },
    })
    
    return { status: 500, body: { error: 'Processing failed' } }
  }
}

Calling Other Steps

export const handler: Handlers<typeof config> = async (input, ctx) => {
  // Invoke another Step directly
  const result = await ctx.invoke('ProcessPayment', {
    orderId: input.orderId,
    amount: input.amount,
  })
  
  return { status: 200, body: result }
}

Best Practices

Keep Handlers Focused

Each Step should do one thing well. Use enqueue() to chain multiple Steps together.

Use Type Safety

Define Zod schemas for inputs and outputs to catch errors early.

Log Liberally

Use structured logging to track execution and debug issues.

Handle Errors Gracefully

Always handle errors and provide meaningful responses.

Use State Wisely

State is for small, frequently accessed data. Use a database for large datasets.

Name Steps Clearly

Use descriptive names that indicate what the Step does (e.g., ‘CreateUser’, ‘ProcessPayment’).

Common Patterns

API + Queue Pattern

Accept requests quickly, process in the background:
// API Step - responds immediately
export const handler = async (req, { enqueue }) => {
  await enqueue({ topic: 'process', data: req.body })
  return { status: 202, body: { status: 'processing' } }
}

// Queue Step - does the heavy work
export const handler = async (input, { logger }) => {
  await heavyProcessing(input)
  logger.info('Processing complete')
}

State Audit Pattern

Track all changes to a resource:
triggers: [
  {
    type: 'state',
    condition: (input) => input.groupId === 'orders',
  },
]

handler: async (input, { logger, enqueue }) => {
  // Log audit trail
  logger.info('Order state changed', {
    orderId: input.itemId,
    from: input.oldValue,
    to: input.newValue,
  })
  
  // Notify if status changed to 'shipped'
  if (input.newValue.status === 'shipped') {
    await enqueue({
      topic: 'email.send',
      data: { orderId: input.itemId },
    })
  }
}

Cron + Queue Pattern

Scheduled tasks that fan out to workers:
// Cron Step - runs every hour
triggers: [{ type: 'cron', schedule: '0 * * * *' }]

handler: async (_, { state, enqueue }) => {
  const users = await state.list('users')
  
  // Enqueue processing for each user
  for (const user of users) {
    await enqueue({
      topic: 'user.process',
      data: { userId: user.id },
    })
  }
}

// Queue Step - processes individual users
triggers: [{ type: 'queue', topic: 'user.process' }]

handler: async (input, { logger }) => {
  await processUser(input.userId)
  logger.info('User processed', { userId: input.userId })
}

Next Steps

Triggers Deep Dive

Learn about all trigger types and options

State Management

Master state storage and retrieval

Real-time Streams

Build real-time features with streams

Examples

View 20+ production-ready examples

Build docs developers (and LLMs) love