writing/tutorial/2026/06
TutorialJun 14, 2026·28 min read

Build a Production AI Backend with Motia: APIs, Background Jobs, and Agents in One Framework

Learn how to build a complete event-driven AI backend with Motia, the unified framework that combines APIs, background jobs, scheduled tasks, real-time streaming, and AI agents around a single Step primitive. We build an AI ticket-triage pipeline end to end.

Modern backends are fragmented. Your HTTP API lives in one framework, your background jobs in a queue system like BullMQ or Celery, your scheduled tasks in a cron runner, your AI agents in some Python service, and your observability is bolted on after the fact. Each piece has its own deployment story, its own mental model, and its own way of failing.

Motia takes a different approach. It unifies APIs, background jobs, scheduled tasks, real-time streaming, state management, and AI agents into a single framework built around one primitive: the Step. If React made everything on the frontend a component, Motia makes everything on the backend a Step — and it lets you mix TypeScript, JavaScript, and Python inside the same workflow.

In this tutorial you will build a complete AI support-ticket triage backend: an HTTP endpoint accepts a ticket, a background event step calls an LLM to classify priority and draft a reply, results stream back to the client in real time, a Python step adds keyword analysis, and a daily cron step generates a digest. By the end you will understand every core Motia concept through working code.

Prerequisites

Before starting, ensure you have:

  • Node.js 20+ and npm installed
  • Python 3.10+ (only needed for the multi-language step in Step 7)
  • Basic knowledge of TypeScript and async/await
  • An OpenAI API key (or any compatible provider) for the AI step
  • A code editor (VS Code recommended)

You do not need prior experience with message queues, cron libraries, or WebSocket servers. Motia provides all of that.

What You'll Build

A four-stage, event-driven pipeline that demonstrates every Motia step type:

  1. API StepPOST /tickets validates the request with Zod and emits an event, returning immediately.
  2. Event Step — subscribes to the event, calls an LLM to classify the ticket and draft a reply, and saves the result to state.
  3. Stream — pushes live status updates ("received" → "analyzing" → "done") to the client over WebSocket.
  4. Python Step — extracts keywords from the ticket text using native Python.
  5. Cron Step — runs every morning to summarize the day's tickets.

The beauty of this architecture is that each stage is decoupled, retried automatically on failure, and observable in a built-in visual debugger.

Step 1: Project Setup

Motia ships an interactive scaffolder. Create a new project:

npx motia@latest create

The CLI asks for a project name, a template, and a language. Pick the TypeScript starter and name the project ticket-triage. Then start the development server:

cd ticket-triage
npm run dev

This launches two things at once: your backend on http://localhost:3000 and the Motia Workbench, a visual console for inspecting, running, and tracing your steps — also at http://localhost:3000. Open it in your browser; you will return to it throughout this tutorial.

A fresh project looks like this:

ticket-triage/
├── steps/                 # every Step lives here, auto-discovered
│   └── hello-world.step.ts
├── .env                   # environment variables
├── package.json
├── tsconfig.json
└── motia-workbench.json   # workbench layout (auto-managed)

The key idea: any file matching *.step.ts, *.step.js, or *_step.py inside steps/ is automatically discovered and wired into the runtime. There is no central router, no manual registration, no app.use(). You write a file, Motia finds it.

Add your API key to .env:

OPENAI_API_KEY=sk-your-key-here

Step 2: Understanding the Step Primitive

Every Step is a single file that exports exactly two things:

  • config — a plain object describing what the step is: its type, how it is triggered, what events it emits or subscribes to, and its validation schemas.
  • handler — an async function containing your business logic.

There are three step types you will use:

TypeTriggered byUse case
apiAn HTTP requestPublic endpoints, webhooks
eventA topic emitted by another stepBackground jobs, AI processing
cronA scheduleDigests, cleanup, polling

Every handler receives a context object as its second argument. The context is where Motia's power lives:

handler = async (input, { emit, logger, state, streams }) => { /* ... */ }
  • emit — fire an event to trigger downstream event steps.
  • logger — structured logging that shows up in the Workbench, correlated per request.
  • state — a built-in key-value store, grouped and persistent, with no database setup.
  • streams — named real-time channels you push data into; clients subscribe over WebSocket.

You never import a queue client, a Redis connection, or a WebSocket server. The context hands them to you, already traced.

Step 3: Create the API Step

Delete the generated hello-world.step.ts and create steps/submit-ticket.step.ts. This is the front door of our pipeline. It validates the incoming ticket with Zod, seeds an initial status in a stream, and emits an event for background processing — then returns instantly so the client never waits on the LLM.

import { ApiRouteConfig, Handlers } from 'motia'
import { z } from 'zod'
import { randomUUID } from 'crypto'
 
export const config: ApiRouteConfig = {
  type: 'api',
  name: 'SubmitTicket',
  description: 'Accepts a support ticket and queues it for AI triage',
  path: '/tickets',
  method: 'POST',
  // Validate the request body before the handler ever runs
  bodySchema: z.object({
    subject: z.string().min(1),
    body: z.string().min(1),
    email: z.string().email(),
  }),
  responseSchema: {
    200: z.object({ ticketId: z.string(), status: z.string() }),
  },
  // This step kicks off the background pipeline
  emits: ['ticket.submitted'],
  flows: ['ticket-triage'],
}
 
export const handler: Handlers['SubmitTicket'] = async (req, { emit, logger, streams }) => {
  const ticketId = randomUUID()
  const { subject, body, email } = req.body
 
  logger.info('Ticket received', { ticketId, email })
 
  // Seed a real-time status the client can subscribe to immediately
  await streams.ticketStatus.set(ticketId, 'status', {
    ticketId,
    stage: 'received',
    priority: null,
    draftReply: null,
  })
 
  // Hand off to the background pipeline and return right away
  await emit({
    topic: 'ticket.submitted',
    data: { ticketId, subject, body, email },
  })
 
  return {
    status: 200,
    body: { ticketId, status: 'received' },
  }
}

Notice three things. The bodySchema means malformed requests are rejected with a 400 before your code runs — no manual validation. The emits array declares the contract: this step produces a ticket.submitted event. And the handler returns in milliseconds because all the slow work happens downstream.

The flows field groups related steps together so the Workbench can draw them as one connected diagram.

Step 4: The Event Step — AI Classification

Now the background worker. Create steps/triage-ticket.step.ts. It subscribes to ticket.submitted, calls an LLM to classify priority and draft a reply, persists the result to state, and updates the stream.

import { EventConfig, Handlers } from 'motia'
import { z } from 'zod'
import OpenAI from 'openai'
 
const openai = new OpenAI({ apiKey: process.env.OPENAI_API_KEY })
 
export const config: EventConfig = {
  type: 'event',
  name: 'TriageTicket',
  description: 'Classifies a ticket and drafts a reply with an LLM',
  subscribes: ['ticket.submitted'],
  // After triage, hand off to keyword extraction (the Python step)
  emits: ['ticket.triaged'],
  input: z.object({
    ticketId: z.string(),
    subject: z.string(),
    body: z.string(),
    email: z.string(),
  }),
  flows: ['ticket-triage'],
}
 
export const handler: Handlers['TriageTicket'] = async (input, { emit, logger, state, streams }) => {
  const { ticketId, subject, body } = input
 
  // Push an intermediate status to the client
  await streams.ticketStatus.set(ticketId, 'status', {
    ticketId,
    stage: 'analyzing',
    priority: null,
    draftReply: null,
  })
 
  const prompt = `You are a support triage assistant. Read the ticket and reply with ONLY JSON:
{"priority":"low|medium|high|urgent","category":"string","draftReply":"a polite 2-sentence reply"}
 
Subject: ${subject}
Body: ${body}`
 
  const completion = await openai.chat.completions.create({
    model: 'gpt-4o-mini',
    messages: [{ role: 'user', content: prompt }],
    response_format: { type: 'json_object' },
  })
 
  const result = JSON.parse(completion.choices[0]?.message?.content || '{}')
  logger.info('Ticket triaged', { ticketId, priority: result.priority })
 
  // Persist the structured result. state.set(groupId, key, value)
  await state.set('tickets', ticketId, {
    ...input,
    ...result,
    triagedAt: new Date().toISOString(),
  })
 
  // Update the live stream so the client sees the final answer
  await streams.ticketStatus.set(ticketId, 'status', {
    ticketId,
    stage: 'triaged',
    priority: result.priority,
    draftReply: result.draftReply,
  })
 
  // Continue the pipeline: keyword extraction happens in Python
  await emit({
    topic: 'ticket.triaged',
    data: { ticketId, text: `${subject} ${body}` },
  })
}

Two things make this production-ready without extra work. First, input is a Zod schema, so the event payload is validated and fully typed inside the handler. Second, if the OpenAI call throws, Motia retries the event automatically based on the step's retry policy — you are not losing tickets to a transient 503.

state.set('tickets', ticketId, value) writes to a persistent, grouped key-value store. The first argument is the group, the second is the key. We will read the whole group back in the cron step.

Step 5: Real-Time Streaming

We have been writing to streams.ticketStatus without defining it. A stream is a named, schema-validated real-time channel. Define it in a .stream.ts file: create steps/ticket-status.stream.ts.

import { StateStreamConfig } from 'motia'
import { z } from 'zod'
 
export const config: StateStreamConfig = {
  name: 'ticketStatus',
  schema: z.object({
    ticketId: z.string(),
    stage: z.string(),
    priority: z.string().nullable(),
    draftReply: z.string().nullable(),
  }),
}

That is the entire setup. Any step can now call streams.ticketStatus.set(groupId, itemId, data) to broadcast an update, and any client can subscribe over WebSocket to receive changes live. In our pipeline, the browser sees receivedanalyzingtriaged push through automatically as each step runs — without polling, without you writing a single line of WebSocket code.

The streams API mirrors state: set(groupId, itemId, data) to write, and delete(groupId, itemId) to remove an item.

Step 6: Add a Python Step (Multi-Language)

Here is where Motia stands apart from single-language frameworks. The same workflow can mix languages: keep your API and orchestration in TypeScript, and drop into Python where the ecosystem is stronger — NLP, data science, ML.

Create steps/extract_keywords_step.py. Note the Python naming convention: the filename ends in _step.py.

import re
from collections import Counter
 
config = {
    "type": "event",
    "name": "ExtractKeywords",
    "description": "Extracts top keywords from a ticket using native Python",
    "subscribes": ["ticket.triaged"],
    "emits": [],
    "flows": ["ticket-triage"],
}
 
STOPWORDS = {"the", "a", "an", "to", "is", "it", "and", "i", "my", "of", "for"}
 
async def handler(input_data, context):
    ticket_id = input_data.get("ticketId")
    text = input_data.get("text", "").lower()
 
    words = [w for w in re.findall(r"[a-z]{3,}", text) if w not in STOPWORDS]
    top = [word for word, _ in Counter(words).most_common(5)]
 
    context.logger.info("Keywords extracted", {"ticketId": ticket_id, "keywords": top})
 
    # Merge keywords into the existing ticket record in shared state
    existing = await context.state.get("tickets", ticket_id) or {}
    existing["keywords"] = top
    await context.state.set("tickets", ticket_id, existing)

When you run npm run dev, Motia detects the Python step, sets up an isolated environment for it, and wires it into the same flow. The Python handler reads from and writes to the exact same state store the TypeScript step used — state.get("tickets", ticket_id) returns what the TypeScript triage step wrote. Cross-language data sharing comes for free because state is part of the framework, not your code.

If your Python step needs third-party packages, add a requirements.txt to the project root and Motia installs them into the step's environment.

Step 7: A Scheduled Cron Step

Finally, a daily digest. Create steps/daily-digest.step.ts. A cron step needs no trigger event — it runs on a schedule you define with standard cron syntax.

import { CronConfig, Handlers } from 'motia'
 
export const config: CronConfig = {
  type: 'cron',
  name: 'DailyDigest',
  description: 'Summarizes the day\'s tickets every morning at 9am',
  cron: '0 9 * * *', // every day at 09:00
  emits: ['digest.ready'],
  flows: ['ticket-triage'],
}
 
export const handler: Handlers['DailyDigest'] = async ({ emit, state, logger }) => {
  // Read every ticket from the 'tickets' group
  const tickets = await state.getGroup('tickets')
 
  const byPriority = tickets.reduce((acc: Record<string, number>, t: any) => {
    const p = t.priority || 'unknown'
    acc[p] = (acc[p] || 0) + 1
    return acc
  }, {})
 
  const digest = {
    date: new Date().toISOString().slice(0, 10),
    total: tickets.length,
    byPriority,
  }
 
  logger.info('Daily digest generated', digest)
  await emit({ topic: 'digest.ready', data: digest })
}

state.getGroup('tickets') returns every value stored under the tickets group as an array — the records written by both your TypeScript and Python steps. From here you could emit digest.ready to an event step that emails the summary or posts it to Slack. Each new capability is just another small Step.

Testing Your Implementation

With npm run dev running, submit a ticket from another terminal:

curl -X POST http://localhost:3000/tickets \
  -H "Content-Type: application/json" \
  -d '{
    "subject": "Cannot log in after password reset",
    "body": "I reset my password but the login page keeps rejecting it. This is blocking my work.",
    "email": "user@example.com"
  }'

You should immediately get back:

{ "ticketId": "a1b2c3d4-...", "status": "received" }

Now open the Workbench at http://localhost:3000. You will see your ticket-triage flow drawn as a connected graph: the API step flowing into the triage event step, which fans out to the Python keyword step. Click any execution to see the per-step logs, the event payloads, the LLM latency, and the state writes — all correlated by the same trace. This is the observability you would normally assemble from three separate tools.

To verify the LLM ran, check the logs for the Ticket triaged entry with its assigned priority, and inspect the tickets state group in the Workbench's state viewer.

Deployment

When you are ready to ship, build and deploy to Motia Cloud:

npm run build
npx motia cloud deploy \
  --api-key "YOUR_MOTIA_API_KEY" \
  --version-name "v1.0.0" \
  --env-file .env

Motia also containerizes cleanly — you can deploy the same project to any platform that runs Docker, as long as your steps/ directory and (for Python) requirements.txt or pyproject.toml ship with it. The runtime that powers npm run dev is the same one that runs in production, so there are no surprises between environments.

Troubleshooting

My step doesn't appear in the Workbench. Check the filename. TypeScript steps must end in .step.ts, Python steps in _step.py, and they must live inside the steps/ directory. Watch the dev server console for a [CREATED] Step line confirming discovery.

The event step never fires. Make sure the emits topic in the producer exactly matches the subscribes topic in the consumer — they are plain strings and must be identical. The Workbench graph will show a disconnected node if a topic has no subscriber.

Zod validation rejects valid requests. Confirm the client sends Content-Type: application/json. The bodySchema runs before your handler, so a mismatch returns a 400 with the validation error in the response body.

The Python step can't import a package. Add it to requirements.txt at the project root and restart npm run dev so Motia rebuilds the Python environment.

State reads return null across languages. Double-check you are using the same group name and key in both languages — 'tickets' and the ticketId. State is shared, but only when the identifiers match exactly.

Next Steps

You now have a working, multi-language, event-driven AI backend with built-in queuing, retries, real-time streaming, scheduling, and observability — and the whole thing is just a folder of small Step files. To extend it:

  • Add a second event step that subscribes to ticket.triaged and auto-replies to urgent tickets.
  • Swap the OpenAI call for a local model and read our guide on running local LLMs in production with vLLM.
  • Add human-in-the-loop approval before sending AI-drafted replies.
  • Explore Motia's noop step type to model external/manual steps in a flow diagram.

For deeper agent patterns, compare this event-driven approach with the AI agent ReAct pattern using the Vercel AI SDK.

Conclusion

Motia's bet is that backend fragmentation is an accident of history, not a necessity. By collapsing APIs, jobs, schedules, streaming, and AI agents onto one Step primitive — and letting those steps be written in whatever language fits the job — it removes a huge amount of glue code and operational surface area. You built a complete AI triage pipeline that, in a traditional stack, would have meant an Express server, a BullMQ worker, a node-cron process, a WebSocket gateway, and a Python microservice, each deployed and monitored separately. Here it was five files in one folder.

The Step is to the backend what the component became to the frontend: a unit small enough to reason about, composable enough to build anything. Start small, emit an event, and let the framework handle the hard parts.