écrits/tutorial/2026/06
Tutorial14 juin 2026·28 min

Construire un backend IA de production avec Motia : APIs, tâches de fond et agents dans un seul framework

Apprenez à construire un backend IA complet et orienté événements avec Motia, le framework unifié qui combine APIs, tâches de fond, tâches planifiées, streaming temps réel et agents IA autour d'une seule primitive : le Step. Nous construisons un pipeline de tri de tickets de support de bout en bout.

Les backends modernes sont fragmentés. Votre API HTTP vit dans un framework, vos tâches de fond dans un système de files comme BullMQ ou Celery, vos tâches planifiées dans un exécuteur cron, vos agents IA dans un service Python quelconque, et l'observabilité est ajoutée après coup. Chaque pièce a sa propre histoire de déploiement, son propre modèle mental et sa propre façon d'échouer.

Motia adopte une approche différente. Il unifie les APIs, les tâches de fond, les tâches planifiées, le streaming temps réel, la gestion d'état et les agents IA dans un seul framework bâti autour d'une primitive unique : le Step. Si React a fait de tout, côté frontend, un composant, Motia fait de tout, côté backend, un Step — et il vous laisse mélanger TypeScript, JavaScript et Python au sein du même workflow.

Dans ce tutoriel, vous construirez un backend complet de tri de tickets de support par IA : un point d'entrée HTTP reçoit un ticket, un Step événementiel de fond appelle un LLM pour classer la priorité et rédiger une réponse, les résultats sont diffusés au client en temps réel, un Step Python ajoute l'analyse de mots-clés, et un Step cron quotidien génère un résumé. À la fin, vous comprendrez chaque concept clé de Motia à travers du code fonctionnel.

Prérequis

Avant de commencer, assurez-vous d'avoir :

  • Node.js 20+ et npm installés
  • Python 3.10+ (nécessaire uniquement pour le Step multilingue à l'étape 7)
  • Des connaissances de base en TypeScript et en async/await
  • Une clé API OpenAI (ou tout fournisseur compatible) pour le Step IA
  • Un éditeur de code (VS Code recommandé)

Vous n'avez pas besoin d'expérience préalable avec les files de messages, les bibliothèques cron ou les serveurs WebSocket. Motia fournit tout cela.

Ce que vous allez construire

Un pipeline orienté événements en quatre étapes qui met en valeur chaque type de Step de Motia :

  1. Step APIPOST /tickets valide la requête avec Zod, émet un événement et renvoie immédiatement.
  2. Step événementiel — s'abonne à l'événement, appelle un LLM pour classer le ticket et rédiger une réponse, et sauvegarde le résultat dans l'état.
  3. Stream — pousse les mises à jour de statut en direct (« reçu » → « en analyse » → « terminé ») vers le client via WebSocket.
  4. Step Python — extrait les mots-clés du texte du ticket en Python natif.
  5. Step Cron — s'exécute chaque matin pour résumer les tickets de la journée.

La beauté de cette architecture : chaque étape est découplée, réessayée automatiquement en cas d'échec, et observable dans un débogueur visuel intégré.

Étape 1 : Configuration du projet

Motia est livré avec un générateur interactif. Créez un nouveau projet :

npx motia@latest create

Le CLI demande un nom de projet, un template et un langage. Choisissez le starter TypeScript et nommez le projet ticket-triage. Puis démarrez le serveur de développement :

cd ticket-triage
npm run dev

Cela lance deux choses à la fois : votre backend sur http://localhost:3000 et le Motia Workbench, une console visuelle pour inspecter, exécuter et tracer vos Steps — également sur http://localhost:3000. Ouvrez-le dans votre navigateur ; vous y reviendrez tout au long de ce tutoriel.

Un projet neuf ressemble à ceci :

ticket-triage/
├── steps/                 # chaque Step vit ici, découvert automatiquement
│   └── hello-world.step.ts
├── .env                   # variables d'environnement
├── package.json
├── tsconfig.json
└── motia-workbench.json   # disposition du workbench (géré automatiquement)

L'idée clé : tout fichier correspondant à *.step.ts, *.step.js ou *_step.py dans steps/ est automatiquement découvert et câblé dans le runtime. Aucun routeur central, aucun enregistrement manuel, aucun app.use(). Vous écrivez un fichier, Motia le trouve.

Ajoutez votre clé API à .env :

OPENAI_API_KEY=sk-your-key-here

Étape 2 : Comprendre la primitive Step

Chaque Step est un fichier unique qui exporte exactement deux choses :

  • config — un objet décrivant ce qu'est le Step : son type, comment il est déclenché, quels événements il émet ou auxquels il s'abonne, et ses schémas de validation.
  • handler — une fonction async contenant votre logique métier.

Il existe trois types de Steps que vous utiliserez :

TypeDéclenché parCas d'usage
apiUne requête HTTPPoints d'entrée publics, webhooks
eventUn topic émis par un autre StepTâches de fond, traitement IA
cronUne planificationRésumés, nettoyage, polling

Chaque handler reçoit un objet context comme second argument. Le context est là où réside la puissance de Motia :

handler = async (input, { emit, logger, state, streams }) => { /* ... */ }
  • emit — déclenche un événement pour activer les Steps événementiels en aval.
  • logger — journalisation structurée qui apparaît dans le Workbench, corrélée par requête.
  • state — un magasin clé-valeur intégré, groupé et persistant, sans configuration de base de données.
  • streams — canaux temps réel nommés vers lesquels vous poussez des données ; les clients s'y abonnent via WebSocket.

Vous n'importez jamais de client de file, de connexion Redis ou de serveur WebSocket. Le context vous les remet, déjà tracés.

Étape 3 : Créer le Step API

Supprimez le fichier hello-world.step.ts généré et créez steps/submit-ticket.step.ts. C'est la porte d'entrée de notre pipeline. Il valide le ticket entrant avec Zod, initialise un statut dans un stream, et émet un événement pour le traitement de fond — puis renvoie instantanément afin que le client n'attende jamais le LLM.

import { ApiRouteConfig, Handlers } from 'motia'
import { z } from 'zod'
import { randomUUID } from 'crypto'
 
export const config: ApiRouteConfig = {
  type: 'api',
  name: 'SubmitTicket',
  description: 'Accepte un ticket de support et le met en file pour le tri IA',
  path: '/tickets',
  method: 'POST',
  // Valide le corps de la requête avant même que le handler ne s'exécute
  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() }),
  },
  // Ce Step démarre le pipeline de fond
  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 })
 
  // Initialise un statut temps réel auquel le client peut s'abonner immédiatement
  await streams.ticketStatus.set(ticketId, 'status', {
    ticketId,
    stage: 'received',
    priority: null,
    draftReply: null,
  })
 
  // Délègue au pipeline de fond et renvoie aussitôt
  await emit({
    topic: 'ticket.submitted',
    data: { ticketId, subject, body, email },
  })
 
  return {
    status: 200,
    body: { ticketId, status: 'received' },
  }
}

Remarquez trois choses. Le bodySchema signifie que les requêtes malformées sont rejetées avec un 400 avant que votre code ne s'exécute — aucune validation manuelle. Le tableau emits déclare le contrat : ce Step produit un événement ticket.submitted. Et le handler renvoie en quelques millisecondes parce que tout le travail lent se déroule en aval.

Le champ flows regroupe les Steps liés afin que le Workbench puisse les dessiner comme un seul diagramme connecté.

Étape 4 : Le Step événementiel — classification par IA

Maintenant le worker de fond. Créez steps/triage-ticket.step.ts. Il s'abonne à ticket.submitted, appelle un LLM pour classer la priorité et rédiger une réponse, persiste le résultat dans l'état et met à jour le 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: 'Classe un ticket et rédige une réponse avec un LLM',
  subscribes: ['ticket.submitted'],
  // Après le tri, délègue à l'extraction de mots-clés (le Step Python)
  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
 
  // Pousse un statut intermédiaire vers le 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 })
 
  // Persiste le résultat structuré. state.set(groupId, key, value)
  await state.set('tickets', ticketId, {
    ...input,
    ...result,
    triagedAt: new Date().toISOString(),
  })
 
  // Met à jour le stream en direct pour que le client voie la réponse finale
  await streams.ticketStatus.set(ticketId, 'status', {
    ticketId,
    stage: 'triaged',
    priority: result.priority,
    draftReply: result.draftReply,
  })
 
  // Poursuit le pipeline : l'extraction de mots-clés se fait en Python
  await emit({
    topic: 'ticket.triaged',
    data: { ticketId, text: `${subject} ${body}` },
  })
}

Deux choses rendent ceci prêt pour la production sans travail supplémentaire. D'abord, input est un schéma Zod, donc la charge utile de l'événement est validée et entièrement typée à l'intérieur du handler. Ensuite, si l'appel OpenAI échoue, Motia réessaie l'événement automatiquement selon la politique de réessai du Step — vous ne perdez pas de tickets à cause d'un 503 transitoire.

state.set('tickets', ticketId, value) écrit dans un magasin clé-valeur groupé et persistant. Le premier argument est le groupe, le second la clé. Nous relirons tout le groupe dans le Step cron.

Étape 5 : Streaming temps réel

Nous écrivions dans streams.ticketStatus sans l'avoir défini. Un stream est un canal temps réel nommé et validé par schéma. Définissez-le dans un fichier .stream.ts : créez 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(),
  }),
}

C'est toute la configuration. N'importe quel Step peut désormais appeler streams.ticketStatus.set(groupId, itemId, data) pour diffuser une mise à jour, et n'importe quel client peut s'abonner via WebSocket pour recevoir les changements en direct. Dans notre pipeline, le navigateur voit receivedanalyzingtriaged se propager automatiquement à mesure que chaque Step s'exécute — sans polling, sans que vous écriviez une seule ligne de code WebSocket.

L'API des streams reflète celle de l'état : set(groupId, itemId, data) pour écrire, et delete(groupId, itemId) pour retirer un élément.

Étape 6 : Ajouter un Step Python (multilingue)

C'est ici que Motia se distingue des frameworks monolingues. Le même workflow peut mélanger les langages : gardez votre API et votre orchestration en TypeScript, et basculez vers Python là où son écosystème est plus fort — TALN, science des données, ML.

Créez steps/extract_keywords_step.py. Notez la convention de nommage Python : le fichier se termine par _step.py.

import re
from collections import Counter
 
config = {
    "type": "event",
    "name": "ExtractKeywords",
    "description": "Extrait les principaux mots-clés d'un ticket avec Python natif",
    "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})
 
    # Fusionne les mots-clés dans l'enregistrement de ticket existant dans l'état partagé
    existing = await context.state.get("tickets", ticket_id) or {}
    existing["keywords"] = top
    await context.state.set("tickets", ticket_id, existing)

Lorsque vous lancez npm run dev, Motia détecte le Step Python, lui configure un environnement isolé et le câble dans le même flow. Le handler Python lit et écrit dans exactement le même magasin state que le Step TypeScript a utilisé — state.get("tickets", ticket_id) renvoie ce que le Step de tri TypeScript a écrit. Le partage de données entre langages est gratuit parce que l'état fait partie du framework, pas de votre code.

Si votre Step Python a besoin de paquets tiers, ajoutez un requirements.txt à la racine du projet et Motia les installe dans l'environnement du Step.

Étape 7 : Un Step Cron planifié

Enfin, un résumé quotidien. Créez steps/daily-digest.step.ts. Un Step cron n'a besoin d'aucun événement déclencheur — il s'exécute selon une planification que vous définissez avec la syntaxe cron standard.

import { CronConfig, Handlers } from 'motia'
 
export const config: CronConfig = {
  type: 'cron',
  name: 'DailyDigest',
  description: 'Résume les tickets de la journée chaque matin à 9h',
  cron: '0 9 * * *', // tous les jours à 09:00
  emits: ['digest.ready'],
  flows: ['ticket-triage'],
}
 
export const handler: Handlers['DailyDigest'] = async ({ emit, state, logger }) => {
  // Lit chaque ticket du groupe 'tickets'
  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') renvoie sous forme de tableau chaque valeur stockée sous le groupe tickets — les enregistrements écrits par vos Steps TypeScript et Python. À partir de là, vous pourriez émettre digest.ready vers un Step événementiel qui envoie le résumé par e-mail ou le publie sur Slack. Chaque nouvelle capacité n'est qu'un petit Step de plus.

Tester votre implémentation

Avec npm run dev en cours d'exécution, soumettez un ticket depuis un autre 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"
  }'

Vous devriez recevoir immédiatement :

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

Ouvrez maintenant le Workbench sur http://localhost:3000. Vous verrez votre flow ticket-triage dessiné comme un graphe connecté : le Step API qui s'écoule vers le Step événementiel de tri, lequel se ramifie vers le Step Python de mots-clés. Cliquez sur n'importe quelle exécution pour voir les journaux par Step, les charges utiles des événements, la latence du LLM et les écritures d'état — le tout corrélé par la même trace. C'est l'observabilité que vous assembleriez normalement à partir de trois outils distincts.

Pour vérifier que le LLM s'est exécuté, cherchez dans les journaux l'entrée Ticket triaged avec sa priorité attribuée, et inspectez le groupe d'état tickets dans le visualiseur d'état du Workbench.

Déploiement

Quand vous êtes prêt à livrer, construisez et déployez vers Motia Cloud :

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

Motia se conteneurise aussi proprement — vous pouvez déployer le même projet sur n'importe quelle plateforme qui exécute Docker, tant que votre répertoire steps/ et (pour Python) requirements.txt ou pyproject.toml sont livrés avec. Le runtime qui propulse npm run dev est le même que celui en production, donc aucune surprise entre les environnements.

Dépannage

Mon Step n'apparaît pas dans le Workbench. Vérifiez le nom de fichier. Les Steps TypeScript doivent se terminer par .step.ts, les Steps Python par _step.py, et ils doivent se trouver dans le répertoire steps/. Surveillez la console du serveur de dev pour une ligne [CREATED] Step confirmant la découverte.

Le Step événementiel ne se déclenche jamais. Assurez-vous que le topic emits du producteur correspond exactement au topic subscribes du consommateur — ce sont de simples chaînes et elles doivent être identiques. Le graphe du Workbench affichera un nœud déconnecté si un topic n'a aucun abonné.

La validation Zod rejette des requêtes valides. Confirmez que le client envoie Content-Type: application/json. Le bodySchema s'exécute avant votre handler, donc une incompatibilité renvoie un 400 avec l'erreur de validation dans le corps de la réponse.

Le Step Python ne peut pas importer un paquet. Ajoutez-le à requirements.txt à la racine du projet et redémarrez npm run dev pour que Motia reconstruise l'environnement Python.

Les lectures d'état renvoient null entre les langages. Vérifiez à nouveau que vous utilisez le même nom de groupe et la même clé dans les deux langages — 'tickets' et le ticketId. L'état est partagé, mais seulement quand les identifiants correspondent exactement.

Pour aller plus loin

Vous avez maintenant un backend IA fonctionnel, multilingue, orienté événements, avec mise en file, réessais, streaming temps réel, planification et observabilité intégrés — et le tout n'est qu'un dossier de petits fichiers Step. Pour l'étendre :

  • Ajoutez un second Step événementiel qui s'abonne à ticket.triaged et répond automatiquement aux tickets urgent.
  • Remplacez l'appel OpenAI par un modèle local et lisez notre guide sur l'exécution de LLM locaux en production avec vLLM.
  • Ajoutez une approbation humaine avant l'envoi des réponses rédigées par l'IA.
  • Explorez le type de Step noop de Motia pour modéliser les étapes externes/manuelles dans un diagramme de flow.

Pour des patrons d'agents plus poussés, comparez cette approche événementielle avec le patron ReAct d'agent IA utilisant le Vercel AI SDK.

Conclusion

Le pari de Motia est que la fragmentation du backend est un accident de l'histoire, pas une nécessité. En réduisant les APIs, les tâches, les planifications, le streaming et les agents IA à une seule primitive Step — et en laissant ces Steps être écrits dans le langage qui convient au travail — il supprime une énorme quantité de code de liaison et de surface opérationnelle. Vous avez construit un pipeline de tri IA complet qui, dans une stack traditionnelle, aurait nécessité un serveur Express, un worker BullMQ, un processus node-cron, une passerelle WebSocket et un microservice Python, chacun déployé et surveillé séparément. Ici, c'était cinq fichiers dans un seul dossier.

Le Step est au backend ce que le composant est devenu au frontend : une unité assez petite pour être raisonnée, assez composable pour tout construire. Commencez petit, émettez un événement, et laissez le framework gérer les parties difficiles.