Skip to main content

Overview

Motia uses an auto-discovery system to find and load your application components at runtime. This eliminates the need for manual registration and keeps your codebase clean and focused.

What Gets Discovered

Motia automatically discovers:
  1. Steps - Files matching **/*.step.{ts,js,py}
  2. Streams - Files matching **/*.stream.{ts,js}
  3. Functions - Registered via WebSocket protocol
  4. Triggers - Defined in step configurations

Step Discovery

File Naming Convention

Steps must follow the naming pattern *.step.{ts,js,py}:
steps/
├── create-todo.step.ts      ✅ Discovered
├── update-todo.step.ts      ✅ Discovered
├── delete-todo.step.py      ✅ Discovered
├── helper-utils.ts          ❌ Not discovered (no .step suffix)
└── todo-types.ts            ❌ Not discovered (no .step suffix)

Discovery Process

1

Glob Pattern Matching

Motia searches for files using glob patterns:
const stepFiles = await glob('**/*.step.{ts,js}', {
  cwd: process.cwd(),
  ignore: ['node_modules/**', 'dist/**'],
})
Reference: motia-js/packages/motia/src/new/build/loader.ts:35-38
2

File Compilation

Each discovered file is bundled with esbuild:
const result = await esbuild.build({
  entryPoints: [file],
  bundle: true,
  platform: 'node',
  format: 'esm',
  write: false,
})
This allows importing dependencies and TypeScript/JSX without separate build steps.Reference: motia-js/packages/motia/src/new/build/loader.ts:55-61
3

Module Loading

The compiled code is written to a temporary file and dynamically imported:
const tempFile = path.join(os.tmpdir(), `motia-step-${Date.now()}.mjs`)
fs.writeFileSync(tempFile, result.outputFiles[0].text)
const module = await import(pathToFileURL(tempFile).href)
fs.unlinkSync(tempFile)
Reference: motia-js/packages/motia/src/new/build/loader.ts:64-68
4

ID Generation

Each step receives a deterministic UUID v5 based on its file path:
const STEP_NAMESPACE = '7f1c3ff2-9b00-4d0a-bdd7-efb8bca49d4f'
const stepId = uuidv5(filePath, STEP_NAMESPACE)
This ensures consistent IDs across deployments and enables reliable trigger registration.Reference: motia-js/packages/motia/src/new/build/loader.ts:29-32
5

Extraction

Motia extracts the config and handler exports:
return {
  config: module.config,
  handler: module.handler,
  filePath: file,
  id: generateStepId(file),
}
Reference: motia-js/packages/motia/src/new/build/loader.ts:71

Excluded Directories

The following directories are excluded from discovery:
  • node_modules/
  • dist/
  • __pycache__/
  • .git/

Stream Discovery

File Naming Convention

Streams must follow the naming pattern *.stream.{ts,js}:
steps/
├── todo.stream.ts           ✅ Discovered
├── inbox.stream.ts          ✅ Discovered
└── shared-types.ts          ❌ Not discovered (no .stream suffix)

Stream Discovery Process

Streams follow the same discovery process as steps:
const streamFiles = await glob('**/*.stream.{ts,js}', {
  cwd: process.cwd(),
  ignore: ['node_modules/**', 'dist/**'],
})

const streams = await Promise.all(streamFiles.map((f) => loadFile(f, 'stream')))
Reference: motia-js/packages/motia/src/new/build/loader.ts:40-45

Stream Registration

Discovered streams are registered by name:
return {
  steps: steps.filter(Boolean) as LoadedStep[],
  streams: Object.fromEntries(
    streamResults
      .filter(Boolean)
      .map((s) => [s?.config?.name, s as LoadedStream])
  ),
}
Reference: motia-js/packages/motia/src/new/build/loader.ts:48-50

Function Registration

Functions are registered dynamically via the WebSocket protocol between workers and the engine.

Registration Message

Message::RegisterFunction {
    id: String,
    description: Option<String>,
    request_format: Option<Value>,
    response_format: Option<Value>,
    metadata: Option<Value>,
    invocation: Option<HttpInvocationRef>,
}
Reference: engine/src/protocol.rs:57-67

Registration Flow

1

Worker Connects

A worker establishes a WebSocket connection to the engine:
pub async fn handle_worker(
    &self,
    socket: WebSocket,
    peer: SocketAddr,
    mut shutdown_rx: tokio::sync::watch::Receiver<bool>,
) -> anyhow::Result<()>
Reference: engine/src/engine/mod.rs:645-650
2

Worker ID Assignment

The engine assigns a unique worker ID and sends it back:
self.send_msg(
    &worker,
    Message::WorkerRegistered {
        worker_id: worker.id.to_string(),
    },
)
.await;
Reference: engine/src/engine/mod.rs:680-686
3

Function Registration

For each step, the worker sends a RegisterFunction message:
await client.send({
  type: 'registerfunction',
  id: stepId,
  description: config.description,
  request_format: config.triggers[0]?.bodySchema,
  response_format: config.triggers[0]?.responseSchema,
})
4

Function Storage

The engine stores the function in the registry:
let function = Function {
    handler: Arc::new(move |invocation_id, input| {
        let handler = handler_arc.clone();
        Box::pin(async move { 
            handler.handle_function(invocation_id, path, input).await 
        })
    }),
    _function_id: function_id.clone(),
    _description: description,
    request_format,
    response_format,
    metadata,
};

self.functions.register_function(function_id, function);
Reference: engine/src/engine/mod.rs:882-895

Trigger Discovery

Triggers are discovered from step configurations and registered with the engine.

Trigger Registration

Message::RegisterTrigger {
    id: String,
    trigger_type: String,
    function_id: String,
    config: Value,
}
Reference: engine/src/protocol.rs:39-44

Trigger Types Registry

The engine maintains a registry of trigger types:
pub struct TriggerType {
    pub id: String,
    pub _description: String,
    pub registrator: Box<dyn TriggerRegistrator>,
    pub worker_id: Option<uuid::Uuid>,
}
Each module (queue, cron, state, stream) registers its trigger type:
Message::RegisterTriggerType { id, description } => {
    let trigger_type = TriggerType {
        id: id.clone(),
        _description: description.clone(),
        registrator: Box::new(worker.clone()),
        worker_id: Some(worker.id),
    };
    
    self.trigger_registry
        .register_trigger_type(trigger_type)
        .await;
}
Reference: engine/src/engine/mod.rs:234-251

Module System

The engine uses a modular architecture where each module is auto-initialized:
pub mod modules {
    pub mod bridge_client;
    pub mod config;
    pub mod cron;
    pub mod http_functions;
    pub mod kv_server;
    pub mod module;
    pub mod observability;
    pub mod pubsub;
    pub mod queue;
    pub mod redis;
    pub mod registry;
    pub mod rest_api;
    pub mod shell;
    pub mod state;
    pub mod stream;
    pub mod telemetry;
    pub mod worker;
}
Reference: engine/src/lib.rs:20-38

Module Trait

Each module implements the Module trait:
#[async_trait::async_trait]
pub trait Module: Send + Sync {
    fn id(&self) -> &str;
    fn name(&self) -> &str;
    async fn initialize(&self) -> anyhow::Result<()>;
    async fn shutdown(&self) -> anyhow::Result<()>;
}

Configuration Discovery

Motia discovers configuration from multiple sources:

1. motia.config.ts

import { defineConfig } from 'motia'

export default defineConfig({
  stepsDir: './steps',
  port: 3000,
  adapters: {
    queue: 'redis',
    state: 'redis',
  },
})

2. Environment Variables

III_ENGINE_URL=ws://localhost:8080
REDIS_URL=redis://localhost:6379
PORT=3000

3. .env Files

Motia automatically loads .env files:
import 'dotenv/config'  // Loaded in motia/src/index.ts
Reference: motia-js/packages/motia/src/index.ts:2

Type Generation

Motia generates TypeScript types for discovered streams and enqueues:
// Auto-generated .motia/types.ts
export interface Streams {
  todo: Stream<Todo>
  inbox: Stream<InboxItem>
}

export interface Enqueues {
  'order.created': OrderCreatedData
  'order.processed': OrderProcessedData
  'user.status.changed': UserStatusChangedData
}
This enables end-to-end type safety across your application.

Hot Reloading

In development mode, Motia watches for file changes and automatically:
  1. Re-discovers modified steps
  2. Unregisters old functions
  3. Registers new functions
  4. Updates trigger registrations
const watcher = chokidar.watch('**/*.step.{ts,js}', {
  ignored: ['node_modules/**', 'dist/**'],
  persistent: true,
})

watcher.on('change', async (filePath) => {
  await reloadStep(filePath)
})

Best Practices

Always use .step.{ts,js,py} and .stream.{ts,js} suffixes for discoverable files.
Place utility functions, types, and constants in separate files without .step or .stream suffixes to avoid unnecessary processing.
Step IDs are generated from file paths. Moving a step file creates a new ID and requires re-registering triggers.
Ensure every step file exports both config and handler. Missing exports will cause discovery to fail silently.
Use the auto-generated types from .motia/types.ts for type-safe stream and enqueue operations.

Debugging Discovery

To troubleshoot discovery issues:

1. Check File Naming

Ensure files match the expected patterns:
find . -name '*.step.ts' -o -name '*.step.js' -o -name '*.step.py'
find . -name '*.stream.ts' -o -name '*.stream.js'

2. Verify Exports

Confirm that config and handler are properly exported:
// ✅ Correct
export const config = { ... }
export const handler = async (input, ctx) => { ... }

// ❌ Incorrect (not exported)
const config = { ... }
const handler = async (input, ctx) => { ... }

3. Check Logs

Look for discovery-related log messages:
motia dev
# Look for:
# "Discovered 5 steps"
# "Registered function: CreateTodo"
# "Registered trigger: http POST /todo"

4. Inspect the Registry

Use the Motia Console to view registered functions and triggers.

Next Steps

Your First Step

Learn recommended project organization

Installation

Explore CLI installation

Workflows

Organize steps into workflows

Examples

View example projects

Build docs developers (and LLMs) love