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.
| Criterion | n8n | TypeScript code (Mastra) |
|---|---|---|
| Relation to the product | runs alongside the product | runs inside the product |
| Logging and tracing | built in, per node | built in via the framework's observability |
| Data model | n8n's own structures | your own domain types, Zod-validated |
| Deployment | a separate n8n instance | the same pipeline as the rest of the product |
| Maintenance | possible without developers | requires a developer team |
| Evaluating runs | manually, in the execution history | scorers 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.
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 involvementThe 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.
