Tutorial
An Agent Loop Is Not an App — Mastra's Harness (Part 3)
Refresh the tab. Approve a delete. Hand off a subtask. Part 3: Mastra's AgentController adds sessions, modes, subagents, and tool approvals — the layer that turns a loop into something users can depend on.
Picture a coding assistant you'd actually trust with your repo.
You ask it to fix a bug. It reads the code, proposes a plan, you approve, it edits the files. Then you close the tab, grab coffee, come back an hour later — and the conversation is exactly where you left it.
Now think about what that quietly requires.
The conversation has to survive the refresh. Not just the messages — the mode it was in, the state it was tracking, everything mid-flight.
It has to behave differently depending on what it's doing. Reasoning about a plan is a different job than editing files, and you don't want the same rules governing both.
Sometimes it needs to hand a subtask to a narrower helper — "go read these twelve files and report back" — without dragging all twelve files into the main conversation.
And before it deletes anything, it should ask.
None of that is "call the model in a loop." It's the layer around the loop — and that layer is what Mastra calls the Harness.
In Part 1 I built the agent. In Part 2 I orchestrated logic around it. Both are the ingredients. Neither one, by itself, is what I just described above.
The series so far
- Agents — the loop, tools, memory.
- Workflows — orchestration with guarantees.
- The Harness (you're here) — the runtime that hosts it all.
- Streaming — get the work to a UI live.
- RAG — answer from real documents.
- Durable agents — survive crashes, run in the background.
- Evals — prove the agent is actually good.
What the harness is — and what it's called
"Harness" is how Mastra frames this layer in its feature lineup, right alongside Agents, Workflows, and Memory.
In the API, it has a more specific name: the
AgentController. The
docs describe it as a "session controller" that manages the pieces "between
your UI and the agent loop: managing conversation threads, switching between
agent modes, persisting state."
You could build all of this yourself on top of the Agent class — none of it
is magic. The harness is just the opinionated version, and you reach for it
when you'd rather not hand-roll a runtime around your agent loop. Mastra's own
terminal coding agent, Mastra Code, is built on it.
The AgentController is in beta as of Mastra 1.46, and the docs note it's
"subject to breaking changes in minor versions until it graduates." The
concepts below are stable; pin your version and check the changelog before
upgrading.
None of this means much in the abstract. Let's wire one up.
Step 1: Wrap an agent in a controller
The controller takes an agent, a storage backend for persistence, and at least one mode — more on modes in a moment.
That last part isn't optional: the modes array is required, and the
controller throws at construction time if it's empty.
import { AgentController } from "@mastra/core/agent-controller";
import { LibSQLStore } from "@mastra/libsql";
import { assistant } from "./agents/assistant";
const controller = new AgentController({
id: "assistant-app",
agent: assistant,
storage: new LibSQLStore({ url: "file:./app.db" }),
modes: [
{ id: "chat", name: "Chat", metadata: { default: true } },
],
});
await controller.init();
await controller.selectOrCreateThread();init() boots the runtime. selectOrCreateThread() binds it to a conversation
thread. From here the controller is live.
But a controller just sitting there isn't interesting yet. What it's holding onto — the actual state of the conversation — is where this gets useful.
Step 2: Read everything from the session
Here's the split that makes the harness click: the controller performs actions; the session is where you read the result.
The docs put it cleanly — the AgentController is "the shared host; the
Session is the conversation running inside it." One controller can serve many
users at once, and nothing leaks between their sessions.
So what does a session actually hold? The current thread, the current mode, the current model, permission grants. Queued follow-ups. Your app's own structured state. And one more thing worth its own paragraph: a display state.
Display state is a single snapshot your UI renders from. Instead of stitching together a dozen callbacks, you subscribe once and re-render on change:
const snapshot = controller.session.displayState.get();
render(snapshot);
controller.subscribe((event) => {
if (event.type === "display_state_changed") {
render(controller.session.displayState.get());
}
});That snapshot carries running totals too — token usage, queued follow-up count — so your UI reads them from one place instead of five.
Reading state is half the picture. The other half is driving the conversation
forward. You queue messages onto the controller — a normal message goes through
followUp. And if the agent is already mid-run and you want to redirect it
without waiting for it to finish, steer injects guidance right into the run:
await controller.followUp({ content: "Summarize today's tickets." });
// While it's working, change course:
await controller.steer({ content: "Actually, just the high-priority ones." });That's enough to hold a real conversation. But real interactive agents don't stay in one gear the whole time — and that's the next piece.
Step 3: Modes — one agent, many behaviors
This is the feature I find most useful in the whole harness.
A mode layers its own instructions and tool overrides on top of the same backing agent. The controller keeps exactly one mode active at a time, and it carries the thread and state across every switch — so the agent can go from planner to builder without losing the conversation underneath it.
The classic shape is a plan → build → review flow. Start with just the first mode:
modes: [
{
id: "plan",
name: "Plan",
metadata: { default: true },
instructions: "Reason about the task. Do not edit files yet.",
tools: { readFileTool, searchTool }, // REPLACES the agent's tools
transitionsTo: "build",
},
],tools here replaces the backing agent's tools for the duration of this
mode. In plan, the agent literally cannot edit a file — the edit tool isn't
in its toolbox at all. There's also a transitionsTo field on that object.
Hold that thought — it matters once we get to switching.
Add a second mode alongside it:
modes: [
{
id: "plan",
name: "Plan",
metadata: { default: true },
instructions: "Reason about the task. Do not edit files yet.",
tools: { readFileTool, searchTool }, // REPLACES the agent's tools
transitionsTo: "build",
},
{
id: "build",
name: "Build",
instructions: "Implement the approved plan.",
additionalTools: { editFileTool }, // ADDS to the agent's tools
},
],additionalTools behaves differently from tools — it augments the agent's
existing toolset instead of replacing it. (You can't set both on a single mode;
pick one.)
One more, and the flow is complete:
const controller = new AgentController({
id: "coding-app",
agent: codingAgent,
storage: new LibSQLStore({ url: "file:./app.db" }),
modes: [
{
id: "plan",
name: "Plan",
metadata: { default: true },
instructions: "Reason about the task. Do not edit files yet.",
tools: { readFileTool, searchTool }, // REPLACES the agent's tools
transitionsTo: "build",
},
{
id: "build",
name: "Build",
instructions: "Implement the approved plan.",
additionalTools: { editFileTool }, // ADDS to the agent's tools
},
{
id: "review",
name: "Review",
instructions: "Critique the changes. Read-only.",
availableTools: ["read_file", "git_diff"], // visibility allowlist
},
],
});review introduces the third knob: availableTools is a per-mode visibility
allowlist on top of whatever's already configured. Three modes, three different
ways of narrowing what the agent can touch.
Switching modes happens through the session. The switch aborts any
in-progress generation, saves the outgoing mode's model, and emits a
mode_changed event:
await controller.session.mode.switch({ modeId: "build" });That's the manual path. Remember transitionsTo from the plan mode above?
That's the automatic one — a mode with transitionsTo advances on its own once
the model decides it's done (say, once a plan is approved), and stays put to
revise if it isn't.
Why bother with this instead of three separate agents? Because three separate
agents would each start cold. With modes, the plan you just approved is still
sitting in context when build picks up — same thread, same state, just
different rules laid over it.
Modes handle switching behavior on the same thread. But sometimes you don't want the same thread at all — you want to hand a task to someone else entirely.
Step 4: Subagents for focused detours
Some subtasks deserve their own narrow agent. "Go read these twelve files and report back" shouldn't pollute the main conversation with twelve files' worth of noise.
Mastra's harness supports subagents for exactly this — child agents with constrained tools that the parent can spawn. The docs describe them as letting "a parent agent delegate focused tasks to child agents with constrained tools and instructions."
You configure them on the controller, and Mastra auto-generates a subagent
tool the parent model can call whenever it wants to spin one up:
const controller = new AgentController({
id: "coding-app",
agent: codingAgent,
modes: [{ id: "build", name: "Build", metadata: { default: true } }],
subagents: [
{
id: "explore",
name: "Explore",
description: "Reads files and gathers context without making changes.",
instructions: "You are a read-only exploration agent.",
allowedWorkspaceTools: ["read_file", "list_directory", "grep_search"],
defaultModelId: "anthropic/claude-haiku-4-5",
maxSteps: 30,
},
],
});allowedWorkspaceTools is the same narrowing idea as modes, just scoped to
this child agent instead of the parent. defaultModelId lets you put a
cheaper model on the job — there's no reason an exploration pass needs your
most expensive model. maxSteps is the leash: a hard cap so a subagent can't
wander forever.
By default a subagent starts fresh. It can't see the parent's messages, so the
parent has to pass everything it needs in the task. That isolation is the
whole point — the explorer burns through files on a cheap model and comes back
with a tidy summary, and the main thread never sees the mess it made getting
there. (If a subtask genuinely needs the full conversation, you can mark a
subagent forked: true to clone the parent thread instead — that requires
memory configured on the controller.)
Subagents keep the mess contained. But there's still one question hanging over all of this: what stops any of these agents — main thread, subagent, doesn't matter — from just deleting a file because the model decided to?
Step 5: Tool approvals — the human-in-the-loop gate
This is the piece that makes me comfortable pointing a tool-using agent at anything that actually matters.
Tool approvals let you require confirmation for risky operations — file writes, deployments, anything destructive — while everything you trust runs straight through, no interruption.
Here's the mechanics. When the agent calls a gated tool, the run pauses and surfaces an approval request through the session. Your UI prompts the user. The run continues or cancels based on the answer. And grants the user approves get remembered on the session, so you're not re-prompting for the same safe action every single turn.
If this feels familiar, it should — it's the same instinct as workflow suspend & resume from Part 2. Pause, defer to a human, continue. Just applied at the granularity of a single tool call, right inside an open conversation instead of a whole workflow step.
That's the last piece. Zoom out, and here's what you've actually got.
The whole picture
Step back and look at what the three parts assembled.
Part 1 gave you an agent — a model that can decide and call tools, with memory for continuity.
Part 2 gave you workflows — explicit orchestration with branching, loops, and durable suspend & resume.
Part 3 gave you the harness — the AgentController runtime, with a live
session, switchable modes, focused subagents, and tool approvals.
Agents decide. Workflows guarantee. The harness hosts. Each layer is useful on its own, but the reason to reach for a framework like Mastra is that they snap together — and you never had to hand-write the loop, the persistence, or the approval gate to get there.
That's the core trio. From here the series turns to what it takes to put this in front of real users: streaming its work to a UI, grounding it in your own documents, keeping long-running agents alive, and proving the thing is actually good. With these three parts behind you, you now have the mental model to read Mastra's own docs the way I do: not as magic, but as named answers to problems you've already built solutions for yourself.