All insights
    Engineering28 July 2026· 10 min read· by Christoph-Thomas Abs

    Building AI agents with TypeScript — and understanding every run

    Building AI agents with TypeScript today is no slower than a no-code workflow. The difference is observability: how do you see in four weeks why a run failed? How we solved this at apis.immo using Mastra — typed, traceable, production-ready.

    ENGINEERING
    Building AI agents with TypeScript — and understanding every run
    ReachOutSoftwareInsights · reachout.software
    In short

    Building AI agents with TypeScript means declaring the agent, its tools and its workflows in code instead of wiring nodes together in a UI. In Mastra an agent is a declaration of instructions, model, tools and memory; every run produces a searchable trace of model calls, tool calls, latency, tokens and cost. The difference to no-code is not how fast you build — it is that four weeks later you can still answer why one specific run failed. Rule of thumb: if the automation runs alongside your product, n8n is enough; if the AI is part of the product, code wins.

    The most honest question we get about AI features usually comes from someone who has already automated something: “Why don't you just build it in n8n?”

    It's a fair question — and the answer isn't “because code is better”. It depends on whether the AI runs alongside the product or inside it. At apis.immo, our platform for property owners, it is part of the product: a chat assistant for owners and an extraction pipeline that turns uploaded WEG documents into structured data. This article shows what that looks like technically.

    Why not just use n8n?

    A quick word upfront, because the question is fair: n8n brings logging, integrations and tracing out of the box. Every node logs, every failed run can be opened, and you immediately see at which step it hung. For a stable process running alongside the product — form in, data enriched, CRM entry, Slack message — that is hard to beat. We recommend it ourselves when no developer will be maintaining the thing afterwards.

    The limit is not “too complex”. It lies where your own data models, domain logic and the same deploy pipeline as the rest of your product enter the picture. At that point you work against the tool instead of with it — and that is exactly where this article starts.

    n8n or custom code — what the decision actually hangs on
    Criterionn8nTypeScript code (Mastra)
    Relation to the productruns alongside the productruns inside the product
    Logging and tracingbuilt in, per nodebuilt in via the framework's observability
    Data modeln8n's own structuresyour own domain types, Zod-validated
    Deploymenta separate n8n instancethe same pipeline as the rest of the product
    Maintenancepossible without developersrequires a developer team
    Evaluating runsmanually, in the execution historyscorers over stored traces

    What does a TypeScript AI agent actually look like?

    A Mastra agent is a declaration: instructions, model, tools, memory. No framework ceremony.

    import { Agent } from "@mastra/core/agent";
    import { Memory } from "@mastra/memory";
    
    export const propertyOwnerAgent = new Agent({
      id: "property-owner-agent",
      name: "Property Owner Assistant",
      instructions: `Du beantwortest Fragen von Wohnungseigentümern
        zu Finanzen, Vorgängen und Dokumenten ihrer Einheit.`,
      model: "openai/gpt-4o-mini",
      memory: new Memory({ options: { lastMessages: 50 } }),
      tools: { financeTool, activitiesTool, documentsTool },
    });

    The tools are the interesting part. A tool is a typed function with a Zod schema — the model never gets open access, only a list of what it is allowed to call:

    import { createTool } from "@mastra/core/tools";
    import { z } from "zod";
    
    export const financeTool = createTool({
      id: "get-unit-finances",
      description: "Liefert Finanzdaten zur Einheit des Nutzers",
      inputSchema: z.object({ period: z.enum(["current", "last-year"]) }),
      execute: async ({ context, runtimeContext }) => {
        const unitId = runtimeContext.get(UNIT_SCOPE_KEY);
        return findFinancesForUnit(unitId, context.period);
      },
    });

    The unit scope comes from the request context, not from the prompt. The model therefore cannot access another owner's data, no matter how creatively someone asks. Authorisation belongs in the code, not in the system instruction.

    In summary, four fields describe the agent completely: instructions, model, memory, tools. Everything else that determines its behaviour sits in the tools — and those are ordinary TypeScript functions with a Zod schema. That is precisely why an agent can be reviewed, tested and versioned like any other application code, instead of living as configuration inside someone else's UI.

    How do you keep multiple agents together in a monorepo?

    Everything at our end lives in a single Nx library, `@apis/agents`. A composition root registers agents, workflows, tools, scorers and storage in exactly one place:

    import { Mastra } from "@mastra/core";
    import { PostgresStore } from "@mastra/pg";
    
    export const mastra = new Mastra({
      agents: { propertyOwnerAgent, fileGroupingAgent,
                propertyDataAgent, accountingAgent },
      workflows: { propertyOnboardingExtraction },
      scorers: { germanScorer, concisenessScorer, relevancyScorer },
      storage: new PostgresStore({ schemaName: "mastra" }),
      observability: { default: { enabled: true } },
      server: { port: 4111 },
    });

    Two applications import this single instance: the owner app for chat and threads, the onboarding service for the extraction workflow. One agent, one definition, the same traces everywhere. On top of that, the same library runs as its own container image so that Mastra Studio works against the real registry — library and deployable in one package, which looks unusual but pays off here.

    The benefit shows up when something changes: a new tool is registered once and is immediately available to every agent and every workflow, without a second application having to be updated. And because the registry is typed, a renamed tool breaks the build — rather than surfacing at runtime in front of a customer.

    TYPED WRITE PATHAgentTyped Tool Callvalidated & safeDB✓ ALLOWEDAgentFree-text JSONunvalidatedDB✕ BLOCKEDReachOutSoftware

    How do you stop an agent from writing freely into the database?

    This is the point where most agent projects tip over — and the point where custom code pays off most clearly.

    The obvious approach: agent extracts data, responds with JSON, application writes the JSON to the database. Works on the happy path and breaks on the first hallucinated structure.

    We separate these cleanly. The agent delivers its extraction via a tool call, not as free text. The tool call is intercepted and collected in a typed capture:

    const run = await agent.generate(prompt, {
      toolChoice: "required",
      runtimeContext,
    });
    
    const extracted = captureToolResult(run, extractPropertyDataTool);
    if (!extracted.ok) return markStepFailed(extracted.issues);
    
    await importPropertyData(extracted.value);  // deterministic, no LLM involvement

    The write operation itself is ordinary TypeScript code: validated, idempotent, testable. The model decides what gets extracted — never how it gets persisted. Side effect: the capture mechanism is race-safe, so multiple parallel runs can share one agent instance without overwriting each other's results.

    What does a workflow that survives failures look like?

    Our onboarding pipeline processes documents in stages: OCR and classification per file, then grouping pages into topic clusters, then three extractions in parallel, then reconciliation.

    export const propertyOnboardingExtraction = createWorkflow({
      id: "property-onboarding-extraction",
      inputSchema: onboardingInputSchema,
    })
      .then(resolveDocuments)
      .foreach(processDocument, { concurrency: 3 })
      .then(groupPages)
      .parallel([extractMasterData, extractActivities, statementProcessing])
      .then(reconcileImports)
      .then(markComplete)
      .commit();

    The most interesting step is `statementProcessing`. WEG financial statements are inconsistent; one pass rarely suffices. So a loop runs there: the agent structures, validation checks, on errors the agent gets sent back with concrete feedback — until the check passes or the pass limit kicks in.

    .dountil(
      extractAndValidateStatement,
      async ({ inputData }) =>
        inputData.isValid || inputData.passes >= MAX_PASSES,
    )

    The state sits in storage, not in process memory. A crashed container loses no half-completed import — the run resumes from the same point.

    For operations that means two things: a deploy in the middle of processing does not cost a full re-import, and a single unreadable document does not take the whole run down with it — the affected step is marked as failed and the rest continues. Failures become data you can analyse rather than support tickets.

    How do traces become evaluations?

    Every one of these steps produces a trace: model call, tool call, handoff, latency, tokens, cost — in a searchable timeline. This makes it answerable what the model did, which tool it reached for and why it reached for it.

    It only gets interesting with scoring. Scorers are automated checks over runs — model-assisted, rule-based or statistical, asynchronous and with configurable sampling. We have three custom ones: one for the language of the reply (actually German, not Denglish or English), one for conciseness, one for relevance.

    export const germanScorer = createScorer({
      id: "german-language",
      description: "Prüft, ob die Antwort auf Deutsch verfasst ist",
    })
      .generateScore(({ run }) => detectLanguage(run.output) === "de" ? 1 : 0);

    From each result a dataset entry can be saved. Over weeks a test collection grows from real runs rather than invented examples — and recently it has become possible to score saved traces retrospectively, without re-running the agent.

    The practical effect: when we swap a model, “has it improved?” is answerable. Before, it was a matter of opinion. That lets us measure and compare the performance of different models on our specific use case — instead of judging them by benchmarks or gut feeling.

    Why does our R&D run through the Claude subscription?

    Because cost anxiety is the fastest way to put the brakes on research. When a four-figure bill runs alongside every experiment, you test more cautiously and find less.

    Mastra can run the agent SDKs of Claude, Cursor and Codex as sub-agents. They inherit the normal agent interface, can be handed to workflows, chatted with in Studio and evaluated with scorers — including logs, traces and costs. At our end this hangs on an environment variable so that the same workflow step can serve both paths:

    export const accountingAgent =
      runtime === "claude-sdk"
        ? new ClaudeSDKAgent({ id: "accounting-agent", sdkOptions: { model } })
        : new Agent({ id: "accounting-agent", model, tools, instructions });

    We don't use this as a coding assistant but as a model backend for our own agents, while we are still stuck on the question “does the approach hold up at all?” You don't get every feature of a direct provider API — for a solid picture it is enough. Cost optimisation belongs at the end of a process, not at the beginning.

    For production client systems it is different: clean API keys, clean billing. The subscription is our research environment, not a cost-saving workaround for production.

    Do you want an AI feature you can actually understand?

    We build AI functionality as software, not as a click-through workflow: typed, observable, in your infrastructure. What that looks like with us is on Claude-first AI — why a prototype isn't enough for that is here. Or write to us directly with what you have in mind: we will tell you honestly whether it needs custom code or whether n8n is enough.

    Frequently asked questions

    What is Mastra?

    An open-source TypeScript framework for AI applications and agents. It bundles agents, workflows, memory, tools and observability and runs embedded in React, Next.js or Node projects, or as a standalone server.

    Do you need a framework to build AI agents with TypeScript?

    No. The overhead doesn't appear with the first agent — it appears with the fifth. By then you are rebuilding tracing, evaluation and tool management yourself.

    How do you prevent hallucinated database entries?

    By having the agent deliver results through typed tool calls and keeping the write operation as deterministic code. The model decides what gets extracted — never how it gets persisted.

    Is this an alternative to n8n?

    Only partially. n8n is a visual automation tool; Mastra is a code library. If the automation runs alongside the product, n8n is often the better choice. If the AI is part of the product, code wins.

    What does it cost to get started?

    The framework core is open source under Apache 2.0. Costs come from model usage and optionally from the hosted platform.

    Christoph-Thomas Abs
    Christoph-Thomas Abs
    Technische Leitung · ReachOut Software
    Connect on LinkedIn
    How many sprints does this take?

    From topic to scope — transparent, in a sprint.

    We translate a topic like this into concrete tickets with estimated hours. Usually one to two 14-day sprints — predictable, cancellable at any time, every hour visible on the Kanban board.

    Setup & architectureBuild the core featureSecurity & reviewGo-live
    Go-to-market

    Software without market entry stays code.

    For go-to-market we recommend our sister company Prometheus Marketing – same Kudamm, same sprint rhythm. ABM target lists, LinkedIn & Microsoft ads, outbound: your first customers and partners.

    Prometheus Marketing