Runtype
ExploreIn production

What Mastra covers, and what customer-facing agents still need

Mastra covers TypeScript agents, workflows, memory and evals. What a Mastra app still needs once outside customers use it: tenancy, config and cost.

Last updated 8 min read

Mastra gives a TypeScript team an agent, a workflow engine and a memory store in one package, with types running from a tool's input schema through to a workflow's output. What it leaves to you is the layer that turns that into something outside customers touch: which account a run belongs to, what each account may configure, what each account costs, and the channels that are not a TypeScript HTTP client.

Nothing below argues for replacing it. The agent and workflow definitions are the parts worth keeping, and most of the work of making a Mastra service safe for customers happens around the run rather than inside it.

What Mastra does well

The framework is TypeScript first in a way that shows up in ordinary work. Tool inputs and workflow step inputs are Zod schemas, so the compiler knows the shape a step receives and the shape it returns, and a rename breaks the build instead of surfacing as a malformed argument three model calls later. Models come from the AI SDK provider packages, so swapping providers is a change to a model reference rather than a rewrite of the calling code.

Workflows are a real engine rather than a diagram. Steps are composed with .then() for sequencing, .branch() for conditions, .parallel() for fan-out and .foreach() for iteration over a collection, each step carrying its own typed input and output, and the graph closed with .commit() before it runs. A step calls suspend() with data matching its suspendSchema, which persists the run to storage and returns control to the caller; run.resume({ resumeData }) restarts it with data validated against the step's resumeSchema. That is the correct shape for a human decision in the middle of a long process, and it does not require holding a process open while a person reads an email.

Memory is the part most frameworks skip. Mastra scopes stored conversation by a resource, the entity the memory belongs to, and a thread, the specific conversation, which is the right pair of keys and the same pair a multi-tenant application needs. On top of that sit a recent-message window (lastMessages), semanticRecall backed by a vector store, and workingMemory: a structured block the agent maintains and rereads, with a scope of 'thread' or 'resource'. Storage and vector adapters are pluggable, so the same memory configuration runs on a local file database in development and Postgres in production.

MCP works in both directions, which is rarer than it sounds. A Mastra app can consume tools from external MCP servers through a client, and it can expose its own agents, tools and workflows as an MCP server that other clients call.

Evals live beside all of this: the package is @mastra/evals, the graders inside it are scorers, and both names are current. Studio, the local UI on port 4111, runs agents, workflows and scorers with their traces visible while you iterate. The Mastra platform, launched in April 2026 as Observability, Studio and Server, takes the same project and runs it with tracing attached, and built-in deployers cover Vercel, Netlify and Cloudflare, with guides for Lambda, Kubernetes and the rest.

The seam

Mastra's contract is a TypeScript process you deploy. Everything above happens inside that process, which is exactly why the pieces compose so cleanly. A feature your own customers use has obligations at the edge of the process, and they do not go away because the agent is well typed.

  • Identity is a value you pass, not a rule that holds. Request context carries an account id because your code put it there. Nothing rejects a run whose context came from an expired session, a queue consumer that defaulted the field, or an internal script running under a shared key. The memory keys have the same property: memory.resource is a string, and a wrong string reads a different customer's thread with no error.
  • Per-account configuration lives in the bundle. An agent is an object constructed at module load, and its instructions, model and tool set are code. Giving one account a different model, an extra tool or a stricter prompt means either a deploy per change or a configuration store you design yourself, plus an answer for which version of an agent a given account was running last Tuesday.
  • Usage is tokens; money is yours to compute. A run reports token counts. Converting those into dollars per account per month is a price table you maintain, a durable write path and a decision about what to do when one customer's spend triples overnight. There is no ceiling that stops a run partway through a turn.
  • Surfaces are an HTTP server, a TypeScript client and MCP. The build output is a server with routes for your agents and workflows, a JavaScript client to call it, and the MCP server noted above. Slack, SMS, a customer-callable API key with scopes, a chat widget on someone else's site: each of those is an application you write, and each is another entry point that can forget to set the account.

Approval on a tool call is where the boundary bites hardest. Workflow suspend and resume covers a pause inside a workflow, but a model deciding mid-turn to call a tool that charges a card is a different shape: the request has to survive minutes of waiting, someone has to be asked, and the answer has to come back into the same turn. Building that yourself means a durable run record, a timeout policy and a resume path, three things that are easy to get almost right.

How to close it

Make request context the only channel for identity

Build the context in one function that takes a verified session and returns the typed object. Every entry point, including the queue consumer and the cron job, calls that function, so there is no second way to assemble an account id.

import { RequestContext } from '@mastra/core/request-context'

type AppContext = {
  accountId: string
  endUserId: string
  plan: 'free' | 'pro'
}

export function contextFor(session: VerifiedSession) {
  const requestContext = new RequestContext<AppContext>()
  requestContext.set('accountId', session.accountId)
  requestContext.set('endUserId', session.userId)
  requestContext.set('plan', session.plan)
  return requestContext
}

Tools then read the account from that object and filter every query by it:

import { createTool } from '@mastra/core/tools'

export const searchTickets = createTool({
  id: 'search-tickets',
  inputSchema: z.object({ query: z.string() }),
  execute: async ({ query }, { requestContext }) => {
    const accountId = requestContext.get('accountId')
    return db.searchTickets({ accountId, query })
  },
})

Version 1.0 changed that signature: execute now takes validated input as its first argument and the execution context as its second, and RuntimeContext became RequestContext. A codemod ships with the upgrade.

One rule carries most of the weight: no tool's input schema contains an account id, a tenant id or a user id. Anything in that schema is a value the model supplies, and a model that can supply an account id can supply the wrong one after reading an injected instruction in a support ticket.

Derive the memory keys from the same object

The memory identifiers deserve the same treatment as a database query. Take the resource identifier from the verified session, never from the request body, and namespace the thread identifier so an identifier guessed or replayed from another account resolves to nothing.

const result = await agent.stream(messages, {
  requestContext,
  memory: {
    resource: `acct_${accountId}:user_${endUserId}`,
    thread: `acct_${accountId}:${conversationId}`,
  },
})

The flat resourceId and threadId arguments were folded into this memory object in version 1.0, so a code sample written before January 2026 will pass identifiers Mastra no longer reads.

Then decide what happens when the context is absent. A run with no account id should fail loudly rather than fall through to a default, because the failure mode of a default is a query that silently spans every customer.

Give per-account configuration a home outside the deploy

The moment a second customer wants a different model or an extra tool, the configuration stops being code. Store what varies as data, version it, and resolve it into request context at the start of a request.

Varies per accountBelongs in
Model and reasoning settingsA config record, resolved per request
Instruction supplements and toneA config record, with a version you can point at
Tool allowlist and connected accountsA config record, enforced when tools are assembled
Spend ceiling and turn limitsA limit checked by the runtime, not by a prompt
The agent's structure and step wiringMastra, in code, under review

Instructions accept a function of request context (instructions: async ({ requestContext }) => ...), so a per-account supplement can be appended without forking the agent. Keep the base instructions in code and treat the per-account part as data with an audit trail, because the question after an incident is which text an account was running, not which text is on main today. The shape of that store, and the failure modes of getting it wrong, are covered in per-customer agent configuration.

Attribute cost and traces before the agent runs

Mastra emits OpenTelemetry data, so the work is choosing a destination and attaching the attributes you will group by later. Set the account and end-user attributes on the root span at the top of the request handler, before the agent is called, so every child span inherits them. Version 1.0 moved this configuration from a telemetry key to an observability one, and the destination is declared in code rather than picked up from the usual environment variables:

import { Observability } from '@mastra/observability'
import { OtelExporter } from '@mastra/otel-exporter'

export const mastra = new Mastra({
  observability: new Observability({
    configs: {
      otel: {
        serviceName: 'support-agent',
        exporters: [
          new OtelExporter({
            provider: {
              custom: {
                endpoint: 'https://collector.example.com/v1/traces',
                protocol: 'http/protobuf',
                headers: { authorization: `Bearer ${process.env.OTLP_TOKEN}` },
              },
            },
          }),
        ],
      },
    },
  }),
})

Decide the prompt and tool-argument capture policy at the same time. Those payloads are where customer data ends up, and a redaction rule is far cheaper to write now than after a year of retained spans. A walkthrough of pointing an externally-run agent's spans at a platform that will read them is at instrumenting an external agent.

Decide what stays in Mastra

Most retrofits go wrong by moving too much. The framework code has the best types and the clearest tests, so leave it alone and put the operational concerns beside it.

Keep in MastraPut around it
Tools, their schemas and their handlersWho a request is for, and rejecting it when unclear
Workflow structure and step wiringCost per account and a ceiling that can stop a run
Memory configuration and recall tuningApproval on tools that spend, send or delete
Studio iterationEval cases promoted from real production runs
Model selection per agentSurfaces: chat, Slack, a customer-callable API, jobs

If the framework choice is still open, the tradeoffs against other TypeScript and Python options are in agent framework comparison, and the platforms that run the agent for you are surveyed in best AI agent platforms. The obligations that appear once customers share one deployment are collected in multi-tenant AI agents, and the category view of the whole surrounding layer is at AI agent platform.

Where Runtype fits

Runtype is the layer around a Mastra service that keeps running as written. Four ways in stack, and porting the loop is optional.

Send traces first. Mastra already emits OpenTelemetry, so one exporter pointed at Runtype's OTLP endpoint gives you the Runs view, trace tree, token usage and a display-only cost estimate, with your provider still billing you for the tokens. Spans carrying the GenAI content attributes add a transcript, capturable as an eval case. Point exactly one instrumentation at it; two double the numbers.

export OTEL_EXPORTER_OTLP_ENDPOINT="https://api.runtype.com/v1/otel"
export OTEL_EXPORTER_OTLP_HEADERS="authorization=Bearer ${RUNTYPE_API_KEY},x-runtype-agent-id=${RUNTYPE_AGENT_ID}"

Setup is at reporting external telemetry, and eval cases then come from recorded runs.

Register the agent next: an external agent whose endpoint speaks Runtype's unified stream (runtype-stream) or A2A lets Runtype call it. It embeds in the open-source Persona widget, joins a product as a capability, answers on web chat, Slack, SMS, REST, MCP and A2A, and runs on a schedule, with cost recorded per run. An A2A endpoint can also be an eval suite target, and the framework code is untouched.

Serve it tools. An MCP surface publishes a product's flows, agents, records, skills and tools, so the Mastra agent stays the orchestrator and calls Runtype for the parts worth centralizing. Rebuild last: port the capability that keeps breaking into a flow once real-run evals prove parity, then gate it with runtype eval run in CI, which exits non-zero on regression.

Request context cannot enforce itself. A resource declares a tenancy strategy (internal, tenant-isolated or end-user-isolated) and an assurance floor (asserted or verified), and a request falling short of it is rejected before execution. Traces, cost and memory keys file under the tenant and end user (end-user identity).

A capability running on Runtype gets the ceiling Mastra leaves to you: approval gates per tool on a five-minute default timeout, maxToolCalls per turn and maxTurns per run. It can also run self-hosted on your own infrastructure.

Frequently asked questions

Is Mastra ready for production use?
The framework layer is. Typed tools, a workflow engine with suspend and resume, memory scoped by resource and thread, MCP in both directions and Studio are production concerns, and Mastra treats them as such. What sits outside its contract is the layer around a customer-facing feature: enforced tenant identity, per-account configuration, cost per account and surfaces that are not a TypeScript HTTP client.
How do I make a Mastra agent multi-tenant?
Put the tenant id in request context, built by one factory that takes a verified session, and read it inside tools rather than accepting it as a tool parameter. Derive the memory resource and thread identifiers from that same object so a request body can never select another account's thread. Then decide what happens when the context is missing, because the framework will happily run without it.
Can Mastra traces go somewhere other than the Mastra platform?
Yes. Mastra emits OpenTelemetry data, so any OTLP collector or backend can receive it, and the exporter is configured on the Mastra instance rather than baked into the deployment target. Check your version's telemetry configuration before wiring it, since the observability configuration has changed shape more than once.
Do I have to leave Mastra to get per-account cost and evals?
No. The agent and workflow definitions are worth keeping regardless of what runs around them. Cost per account, eval cases promoted from real production runs and approval gates attach to the run boundary and the spans, not to the agent code, so they can be added without rewriting the framework layer.