{"slug": "building-a-multi-agent-ai-pipeline-with-mastra-and-typescript", "title": "Building a Multi-Agent AI Pipeline with Mastra and TypeScript", "summary": "Clause AI, a platform for analyzing rental and lease agreements, uses a multi-agent AI pipeline built with Mastra and TypeScript. The system coordinates four specialized agents—for parsing, summarizing, risk analysis, and query answering—each with tuned model parameters and structured output schemas. The architecture handles failures gracefully by persisting intermediate state and skipping completed work on retries, ensuring reliable and responsive document analysis.", "body_md": "Building an AI feature is easy. Building a reliable multi-agent pipeline that coordinates four specialized AI agents, persists intermediate state, skips already-completed work on retries, and keeps the API responsive while the models think — that is the hard part.\n\nThis post walks through the architecture behind ** Clause AI**, a platform that analyzes rental and lease agreements. It extracts key terms, flags risky clauses, and lets users chat with their contracts using RAG (Retrieval-Augmented Generation) — all powered by a coordinated pipeline of specialized AI agents.\n\nThe naive approach to building an AI-powered document analysis tool would be a single function that calls an LLM, parses the output, and saves it to a database. It works until it doesn't.\n\nThe moment you introduce multiple steps — parsing, summarizing, embedding, risk analysis — things break:\n\nClause AI was designed from the start to handle these failure modes, not as an afterthought.\n\nRather than one monolithic prompt that tries to do everything, the system uses four purpose-built agents — each with a focused responsibility, tuned model parameters, and a structured output schema.\n\n| Agent | Responsibility | Model Settings |\n|---|---|---|\n| Parser Agent | Extracts entities, dates, parties, payments, and clause-level structure | Low reasoning, temp 0.2 |\n| Summary Agent | Converts legal jargon into plain-English bullet points | Low reasoning, temp 0.6 |\n| Risk Agent | Flags risky or unfair clauses with severity scoring | Medium reasoning, temp 0.4 |\n| Query Agent | Answers user questions via RAG with tool use | Medium reasoning, temp 0.7 |\n\nEach agent is configured with distinct reasoning levels and temperatures. The Parser Agent runs at a low temperature (0.2) because extraction requires precision — you want deterministic, faithful reproduction of what the document says. The Query Agent runs warmer (0.7) because conversational responses benefit from more natural phrasing.\n\nEvery agent produces validated, structured output using Zod schemas. The Parser Agent, for example, returns a typed object with nullable fields — if information is missing from the document, the agent returns `null`\n\nrather than hallucinating data.\n\n``` js\nconst ResponseSchema = z.object({\n    title: z.string().nullable(),\n    type: z.enum(AGREEMENT_TYPES).nullable(),\n    metadata: z\n        .object({\n            effectiveDate: z.string().nullable(),\n            expiryDate: z.string().nullable(),\n            autoRenewal: z.boolean().nullable(),\n            governingLaw: z.string().nullable(),\n        })\n        .nullable(),\n    parties: z\n        .array(\n            z.object({\n                name: z.string(),\n                role: z.enum(AGREEMENT_PARTY_ROLES),\n                address: z.string().nullable(),\n            }),\n        )\n        .nullable(),\n    sections: z\n        .array(\n            z.object({\n                ref: z.string(),\n                type: z.enum(SECTION_CLAUSE_TYPES),\n                heading: z.string(),\n                content: z.string(),\n            }),\n        )\n        .nullable(),\n    error: z.string().nullable(),\n});\n```\n\nThis schema-first approach means downstream agents and database writes can trust the shape of the data they receive. No defensive parsing, no \"hope the LLM returned the right format\" — it either validates or it fails.\n\nThe orchestration layer is where the architecture earns its complexity budget. The project uses ** Mastra** — a TypeScript-native agent orchestration framework — to define workflows as composable, sequential pipelines with branching, iteration, and shared state.\n\nHere is the main workflow definition, stripped to its essence:\n\n``` js\nexport const agentWorkflow = createWorkflow({\n    id: \"agent-workflow\",\n    inputSchema: z.any(),\n    outputSchema: z.object({ status: z.string() }),\n    stateSchema: WorkflowStateSchema,\n})\n    .then(initiateStateHydration)\n    .branch([[async ({ state }) => !state.skipParserAgent, parsingWorkflow]])\n    .branch([[async ({ state }) => !state.skipSummaryAgent, summaryWorkflow]])\n    .then(embeddingWorkflow)\n    .branch([[async ({ state }) => !state.skipRiskAgent, riskWorkflow]])\n    .then(finishStep)\n    .commit();\n```\n\nThe `.then()`\n\ncalls chain steps sequentially. The `.branch()`\n\ncalls conditionally execute sub-workflows based on runtime state. This is where fault tolerance comes in — but more on that in the next section.\n\nEach sub-workflow itself is a two-step pattern: **LLM call → DB persistence**.\n\n``` js\nexport const summaryWorkflow = createWorkflow({\n    id: \"summary-workflow\",\n    inputSchema: z.any(),\n    outputSchema: z.any(),\n    stateSchema: WorkflowStateSchema,\n})\n    .then(summaryAgentStep) // LLM call: generate summary\n    .then(storeSummaryStep) // DB call: persist to Postgres\n    .commit();\n```\n\nThis separation is deliberate. The LLM step writes only to workflow state — an in-memory, transient object. The DB step is a separate, retryable operation. If the DB write fails, the LLM result isn't lost; it lives in state and can be retried without re-running the expensive model call.\n\nThe most critical design pattern in the entire system is **state hydration** — the first step of every pipeline run.\n\nBefore any agent executes, the workflow reads the current state of the agreement from the database. If a previous run already completed the parsing step (title, type, parties, and sections exist in the DB), the hydration step sets `skipParserAgent: true`\n\nin the workflow state. The main workflow's `.branch()`\n\nsees this flag and skips the parsing sub-workflow entirely.\n\n``` js\nconst hydrateWorkflowState = async (agreementId, userId, state) => {\n    const agreement = await AgreementsService.fetchAgreement(\n        agreementId,\n        userId,\n    );\n\n    const [dbSections, dbRisks] = await Promise.all([\n        AgreementsService.fetchSectionsByAgreement(agreementId, userId),\n        AgreementsService.fetchRisksByAgreement(agreementId, userId),\n    ]);\n\n    return {\n        ...state,\n        title: agreement.title,\n        sections: dbSections.length > 0 ? dbSections : state.sections,\n        risks: dbRisks.length > 0 ? dbRisks : state.risks,\n        // Skip flags based on what already exists\n        skipParserAgent: Boolean(\n            agreement.title &&\n            agreement.metadata &&\n            agreement.parties &&\n            dbSections.length > 0,\n        ),\n        skipSummaryAgent: Boolean(agreement.summary),\n        skipRiskAgent: Boolean(dbRisks.length > 0),\n    };\n};\n```\n\nThis means:\n\nThere is also a `forceRestart`\n\nflag that bypasses hydration entirely, useful when the user explicitly wants to re-process a document from scratch.\n\nNot every step in the pipeline is a simple A→B chain. The embedding and risk workflows use Mastra's `.foreach()`\n\nprimitive to fan out work across multiple items.\n\nAfter parsing, each section needs a vector embedding for semantic search. The embedding workflow:\n\n`.foreach()`\n\n, running each section through a per-section sub-workflow.\n\n``` js\nexport const embeddingWorkflow = createWorkflow({\n    id: \"embedding-workflow\",\n    inputSchema: z.any(),\n    outputSchema: z.any(),\n    stateSchema: WorkflowStateSchema,\n})\n    .then(prepareEmbeddingSectionsStep)\n    .foreach(embeddingPerSectionWorkflow)\n    .commit();\n```\n\nThe risk workflow follows a similar fan-out pattern, but with a twist: sections are grouped by clause type before analysis. Instead of analyzing 15+ individual sections, the system groups them into logical categories (Rent, Termination, Maintenance, etc.) and analyzes each group in a single LLM call. This reduces the number of API calls while keeping each prompt focused.\n\n``` js\nexport const riskWorkflow = createWorkflow({\n    id: \"risk-workflow\",\n    inputSchema: z.any(),\n    outputSchema: z.any(),\n    stateSchema: WorkflowStateSchema,\n})\n    .then(prepareRiskSectionsStep) // Group sections by type\n    .foreach(riskAnalyzeStep) // Analyze each group\n    .then(storeRiskResultStep) // Persist all results\n    .commit();\n```\n\nThe risk scoring itself is intentionally conservative. The Risk Agent's system prompt explicitly states that \"absence of risk is a valid and expected outcome\" and sets a high bar: only flag issues that are \"risky enough to mention in a legal memo.\" Each identified risk gets a numeric score (0–100) that maps to severity levels.\n\n| Score Range | Severity |\n|---|---|\n| 0–40 | LOW |\n| 41–60 | MEDIUM |\n| 61–80 | HIGH |\n| 81–100 | CRITICAL |\n\nOnce the processing pipeline completes, the agreement is ready for interactive Q&A. The Query Agent is architecturally different from the other three — it runs on-demand per user question rather than as part of the batch pipeline, and it uses **tool calling** to decide what information it needs.\n\nThe agent has access to two tools:\n\n`fetchSectionsTool`\n\n`fetchRisksTool`\n\nThe critical design choice here is that the agent decides whether to use tools at all. For simple questions that can be answered from the agreement's metadata (already injected into the system prompt) or from conversation history, no tool call is made. This keeps simple queries fast.\n\n``` js\n// Token-budgeted retrieval instead of fixed top-K\nconst selectedSections = [];\nfor (const s of sections) {\n    const tokens = estimateTokens(s.content) + estimateTokens(s.heading);\n\n    if (tokens + usedTokens > maxTokens) break;\n    usedTokens += tokens;\n\n    selectedSections.push({\n        section: s.ref,\n        heading: s.heading,\n        content: s.content,\n        similarity: s.similarity,\n    });\n}\n```\n\nThe sections tool uses **token-budgeted retrieval** rather than a fixed top-K count. Since the Query Agent already carries conversation history and agreement metadata in its context window, blindly returning 10 sections could overflow the context and degrade response quality. Instead, sections are added until a token cap is reached, regardless of how many or how few that turns out to be.\n\nThe Q&A flow is fully asynchronous. When a user sends a question:\n\nThis keeps the API responsive even when the model takes several seconds to reason through a complex question.\n\nThe entire processing pipeline is decoupled from the API layer through ** BullMQ** (Redis-backed job queues). When a user uploads a document, the API doesn't start AI processing inline — it enqueues a job.\n\nThis solves two problems:\n\nThe message worker handles both file processing jobs and email notification jobs, routing based on job name:\n\n```\nif (job.name === PROCESS_FILE_JOB) {\n    await WorkflowService.startAgreementProcessing(agreementId, fileId, userId);\n} else if (job.name === EMAIL_NOTIFICATION_JOB) {\n    await NotificationService.sendEmailNotification(email, type, payload);\n}\n```\n\nEvery agent is configured with multiple model fallbacks. If the primary model returns a 429 (rate limited), the system marks it as unavailable for the duration specified in the `retry-after`\n\nheader and automatically falls back to the next available model.\n\n``` js\nconst parserAgent = new Agent({\n    id: \"parser-agent\",\n    name: \"Parser Agent\",\n    instructions: Instructions,\n    model: getAvailableModels().map((model) => ({\n        id: model,\n        model: model,\n        modelSettings: {\n            reasoning: \"low\",\n            temperature: 0.2,\n        },\n    })),\n});\n```\n\nThis means the pipeline doesn't fail because of a temporary rate limit — it gracefully degrades to a different model and continues processing.\n\nHere is the full pipeline from upload to interactive Q&A:\n\nEvery step is discrete, retryable, and idempotent. Intermediate state is persisted between steps. The workflow can be interrupted and resumed without losing progress or wasting API calls.\n\nBuilding a multi-agent system isn't about calling multiple LLMs — it's about **orchestrating** them. The real engineering work is in the scaffolding:\n\n`.foreach()`\n\nThe multi-agent approach isn't just an architectural choice — it's a reliability strategy. Each agent has a focused responsibility, a clear contract, and a failure boundary that doesn't contaminate the rest of the pipeline.", "url": "https://wpnews.pro/news/building-a-multi-agent-ai-pipeline-with-mastra-and-typescript", "canonical_source": "https://dev.to/bibekkakati/building-a-multi-agent-ai-pipeline-with-mastra-and-typescript-1fjk", "published_at": "2026-08-22 19:28:33+00:00", "updated_at": "2026-08-22 19:43:32.418882+00:00", "lang": "en", "topics": ["artificial-intelligence", "ai-agents", "ai-products", "developer-tools", "machine-learning"], "entities": ["Clause AI", "Mastra", "TypeScript"], "alternates": {"html": "https://wpnews.pro/news/building-a-multi-agent-ai-pipeline-with-mastra-and-typescript", "markdown": "https://wpnews.pro/news/building-a-multi-agent-ai-pipeline-with-mastra-and-typescript.md", "text": "https://wpnews.pro/news/building-a-multi-agent-ai-pipeline-with-mastra-and-typescript.txt", "jsonld": "https://wpnews.pro/news/building-a-multi-agent-ai-pipeline-with-mastra-and-typescript.jsonld"}}