DEV Community

Faithful Ojebiyi
Faithful Ojebiyi

Posted on

Building Production AI Agents with Mastra, NestJS, Inngest, and Next.js

Lessons from shipping agents in two real products — a legal AI platform and a carbon data platform.


Everyone can build an AI demo in a weekend. The hard part starts when the agent has to survive production: a run that takes four minutes and can't die when a pod restarts, a tool call that mutates customer data and needs a human to say "yes" first, a stream of tokens that has to reach a browser through two services and a queue.

Over the past months I've built AI agents into two production systems with the same stack:

  • Gavely — a legal AI platform where agents plan multi-step document reviews, extract structured data from contracts into spreadsheet-like grids, and draft documents.
  • CarbonMe — a carbon data platform where an agent team profiles raw warehouse tables, authors SQL transforms, and builds a semantic layer — with a human approving every materializ ation.

Both run on the same four pillars: NestJS (structure), Mastra (agents), Inngest (durability), and Next.js (the UI). Here's how the pieces fit, and what I'd tell anyone
starting the same journey.

The architecture in one paragraph

Two NestJS apps in a monorepo: an api app that handles HTTP and an isolated worker app that runs long agent jobs. They never import each other — they communicate exclusively th
rough Inngest events with zod-typed payloads. Mastra hosts the agents (model config, tools, memory) as a shared library both apps can use. The Next.js dashboard talks to the api ov
er REST and consumes agent progress over SSE. Postgres holds business data via Prisma, and Mastra keeps its own memory tables in a separate schema so the two never fight over migration
s.

Next.js ──HTTP/SSE──▶ NestJS api ──Inngest events──▶ NestJS worker
                          │                              │
                          └────────── Mastra agents ─────┘
                                        │
                              Postgres (+ separate
                               mastra schema)
Enter fullscreen mode Exit fullscreen mode

Why split api and worker at all? Because agent runs are minutes, not milliseconds. You don't want a 4-minute LLM loop holding an HTTP connection open, blocking your event loop, or dy
ing silently when you deploy. The api's job is to accept the request, write a row, publish an event, and return in 50ms. The worker's job is to do the slow, failure-prone thing — with
retries.

1. Agents are code, and they live in a library

Mastra's core primitive is the Agent: a model, instructions, tools, and oplick for me is that everything can be dynamic per request:

export const legalAssistantAgent = new Agent({
  id: 'legal-assistant',
  // instructions loaded from DB — base prompt + the workflow's skill file
  instructions: async ({ requestContext }) => {
    const workflow = await loadWorkflow(requestContext.get('workflowId'));
    return withWorkflow(BASE_PROMPT, workflow?.title, workflow?.promptMd);
  },
  model: ({ requestContext }) => resolveModel(requestContext.get('model')),
  tools: ({ requestContext }) => ({ /* conditionally registered tools */ }),
});
Enter fullscreen mode Exit fullscreen mode

Instructions come from the database (so product can iterate on prompts without a deploy), the model is selectable per request, and tools appear only when their backing service is wiredup.

For bigger jobs, Mastra's supervisor pattern shines. In CarbonMe, a "semantic manager" agent delegates to three focused sub-agents instead of one god-agent with 12 tools:

return new Agent({
  id: 'semantic',
  instructions: `Plan the work and DELEGATE to your sub-agents:
    - data: profile tables and AUTHOR cleaning transforms
    - modeling: define the semantic model (dimensions + metrics)
    - query: answer questions against PUBLISHED models`,
  model: 'anthropic/claude-sonnet-4-6',
  memory: new Memory({ options: { lastMessages: 20 } }),
  agents: { data, modeling, query },
});
Enter fullscreen mode Exit fullscreen mode

Small tool sets per agent means better tool selection, cheaper context, and instructions that actually fit in the model's head.

2. Never let the model tell you who the user is

This is the security pattern I'd put on a billboard. Multi-tenant + LLM = a model that can be prompt-injected into asking for someone else's data. The fix: tenant identity never comes from model input. It flows from the authenticated HTTP request, through NestJS's async local storage, into Mastra's RequestContext — and tools read it from there:

// in the CQRS command handler (api)
const requestContext = new RequestContext([
  ['organizationId', this.als.ctx.get('organizationId')],
  ['projectId', projectId],
  ['userId', userId],
]);
await agent.stream(message, { requestContext, memory: { thread: conversationId, resource: userId } });

// in every tool
execute: async (inputData, ctx) => {
  const { organizationId, projectId } = requireTenant(ctx); // from context, NEVER from input
  return runner.run({ organizationId, projectId, query: Schema.parse(inputData) });
}
Enter fullscreen mode Exit fullscreen mode

The model decides what to do; the platform decides on whose behalf. Those must never mix.

3. Inngest makes agent runs durable — and CQRS keeps them testable

An agent run in Gavely is a state machine: PENDING → PLANNING → AWAITING_APPROVAL → RUNNING → DONE, with pauses for human input. The api starts it with three lines of intent:

const run = await prisma.agentRun.create({ data: { brief, organizationId, ..
await eventPublisher.sendEvent({
  id: run.id, // event id = run id → redeliveries are idempotent
  name: EVENT_KEYS.AGENT_RUN_REQUESTED,
  data: { agentRunId: run.id, organizationId },
});
Enter fullscreen mode Exit fullscreen mode

The worker consumes it with a deliberately thin Inngest function — every step just dispatches a NestJS CQRS command:

inngest.createFunction(
  { id: 'agent-run-requested', retries: 1, triggers: [EVENTS.AGENT_RUN_REQUESTED] },
  async ({ event, step }) => {
    await step.run('plan-agent-run', () =>
      commandBus.execute(new PlanAgentRunCommand(event.data)));
  },
);
Enter fullscreen mode Exit fullscreen mode

This split is the whole trick: Inngest owns retries, memoization, and durability; NestJS command handlers own business logic. Steps that already succeeded don't re-run after a crash. And because the handler is a plain injectable class, it's unit-testable without a queue in sight.

For multi-stage pipelines it scales beautifully. Gavely's list extraction is seven step.run calls — plan, generate, reconcile, check conflicts, commit, summarize, project — each one a command, each one independently retried. Fan-out is one more primitive: ex uses step.invoke to spawn one child function per document, all concurrent.

One more detail that pays for itself: every event payload is a zod schema in a central registry, so publishing a typo'd event or a malformed payload is a compile error, not a 2 a.m. incident.

4. Humans in the loop, two ways

Autonomous agents that mutate customer data are a liability. Both products gate the dangerous moments, with two different mechanisms worth knowing:

Gate on the plan (Gavely). The planning agent must call a submit_plan ut here's the twist — the tool doesn't do anything. The worker watches the stream, and the tool call itself is the control-flow signal:

for await (const chunk of stream.fullStream) {
  if (chunk.type === 'tool-call' && chunk.payload.toolName === 'submit_plan') {
    return { submitPlan: PlanInputSchema.parse(chunk.payload.args) }; // → AWAITING_APPROVAL
  }
  if (chunk.type === 'tool-call' && chunk.payload.toolName === 'ask_user') {
    return { askUser: chunk.payload.args.questions }; // → AWAITING_INPUT
  }
}
Enter fullscreen mode Exit fullscreen mode

The run suspends, the UI renders a plan card with an Approve button, and approval publishes a fresh Inngest event that wakes the worker up. The same ask_user tool lets the agent pause mid-execution to ask clarifying questions — the answer comes back as the next event.

Gate on the tool (CarbonMe). Mastra has first-class support for this: mark a tool requireApproval: true and the stream emits an approval chunk instead of executing:

createTool({
  id: 'run_transforms',
  description: "'Materialize transforms against the warehouse',"
  requireApproval: true, // human reviews the SQL before it touches data
  execute: async (_input, ctx) => { /* only runs after approval */ },
});
Enter fullscreen mode Exit fullscreen mode

The chat UI shows a Run/Decline card; approving resumes the same run — no rewind, no lost context. The agent authors SQL, the human clicks Run. "You author, the human runs" is even written into the agent's instructions.

5. Streaming to the browser: journal first, stream second

The naive approach — pipe the LLM stream straight to the HTTP response — breaks the moment the run outlives the request or the user refreshes the page. Both products instead treat the database as the source of truth and the stream as a live view.

As the worker consumes the agent stream, every meaningful chunk (text delta, tool call, tool result, status change) is journaled as a row in an agentRunEvent table, then published over Redis pub/sub for anyone listening:

const journal = (event) =>
  prisma.agentRunEvent.create({ data: { runId, type: event.type, ... } })
    .then((row) => onPublish?.(row)); // → SSE subscribers
Enter fullscreen mode Exit fullscreen mode

The Next.js side reads SSE frames with a small custom reader (more control than EventSource), and a useChat hook folds events into React state:

if (event.type === 'text')      patch((m) => ({ ...m, text: m.text + event.text }));
if (event.type === 'tool-call') patch((m) => ({ ...m, tools: [...m.tools, event.tool] }));
if (event.type === 'approval')  patch((m) => ({ ...m, approval: { runId, toolCallId } }));
Enter fullscreen mode Exit fullscreen mode

Because the journal is durable, the timeline UI is trivially resumable: reload the page, fetch the events, re-render the exact same thread — streamed text, tool steps with spinners/che
ckmarks, plan cards, question gates, and deliverable cards anchored to the tool results that produced them. The live stream is just the tail.

6. Lessons I'd hand to my past self

  1. Split api and worker from day one. Agent workloads and HTTP workloads have opposite performance profiles. Different apps, shared libraries, events in between.
  2. Keep Inngest functions thin. step.run → commandBus.execute(...) and nothing else. Your business logic shouldn't know a queue exists.
  3. Give Mastra its own Postgres schema. Agent memory tables and Prisma migrations must never collide. One connection string, two schemas, zero conflicts.
  4. Tools that only signal are a superpower. submit_plan and ask_user execute nothing — they're typed control-flow markers the orchestrator reacts to. Cheapest state machine you'll ever build.
  5. Zod at every boundary. HTTP DTOs, event payloads, tool inputs, workflow step outputs. If it crosses a process boundary, it has a schema. This is what keeps a 4-service async sys tem debuggable.
  6. Journal, then stream. DB rows are truth; SSE is a projection. Refreshes, reconnects, and multi-device just work.
  7. Design the approval UX before the agent. The gates (plan approval, tool approval, clarifying questions) shaped more of the architecture than the prompts did.

The stack, honestly assessed

  • Mastra gave me agents-as-code with per-request dynamism, sub-agent delegation, built-in memory, and tool approval — without owning a single line of LLM-loop plumbing. (One caveat: its NestJS adapter is Express-only; we run Fastify, so we wrote a thin adapter.)
  • Inngest turned "long-running AI job" from a distributed-systems problem into a function with steps. Retries, idempotency, fan-out, and a local dev UI included.
  • NestJS kept two apps and a growing feature set coherent — CQRS handlers, DI, and zod-derived DTOs mean every feature looks like every other feature.
  • Next.js plus a small SSE reader and one hook was all the frontend needed to make agents feel alive.

If you're building agents that have to survive contact with real users and r the most leverage per line of code I've found.


Building something similar, or stuck on one of these pieces? I'm happy to c or DM.

Top comments (0)