Guide

Building Agents with Harness

The Mastra Harness class orchestrates multiple agent modes, shared state, memory, and storage into a single runtime. Think of it as the control layer between your UI and your agents.

What is Harness?

A plain Agent handles one conversation in one mode. The Harness wraps multiple agents and adds what production apps need:

  • Modes — switch between "plan," "build," "research" agents mid-conversation
  • Shared state — validated with a schema, persisted across sessions
  • Thread management — create, switch, list conversation threads
  • Tool approvals — human-in-the-loop for dangerous operations
  • Permissions — grant/deny tool categories per session
  • Subagents — spawn focused agents that fork the parent context
  • Observational memory — background consolidation of conversation history
When to use Harness vs Agent: Use Agent for simple single-turn or chat use cases. Use Harness when you need modes, persistent threads, tool approvals, or subagent delegation.

Setup

Install the packages:

terminal
pnpm add @mastra/core @mastra/memory @mastra/libsql

Create your first harness with a single mode:

src/mastra/harness.ts
import { Harness } from '@mastra/core/harness';
import { Agent } from '@mastra/core/agent';
import { Memory } from '@mastra/memory';
import { LibSQLStore } from '@mastra/libsql';

const storage = new LibSQLStore({
  url: process.env.DATABASE_URL ?? 'file:./local.db',
});

const memory = new Memory({
  storage,
  options: {
    lastMessages: 30,
    workingMemory: { enabled: true },
  },
});

const planAgent = new Agent({
  id: 'plan',
  name: 'Planner',
  instructions: 'You break down tasks into clear steps.',
  model: 'openai/gpt-4o-mini',
});

const buildAgent = new Agent({
  id: 'build',
  name: 'Builder',
  instructions: 'You implement code based on a plan.',
  model: 'openai/gpt-4o-mini',
});

export const harness = new Harness({
  modes: [
    { id: 'plan', agent: planAgent, default: true },
    { id: 'build', agent: buildAgent },
  ],
  storage,
  memory,
});

Modes

Modes are agent configurations the harness can switch between. Each mode has its own agent with distinct instructions, tools, and model. The user (or your UI) triggers switches.

src/app/api/chat/route.ts
import { harness } from '@/mastra/harness';

export async function POST(req: Request) {
  const { message, threadId, mode } = await req.json();

  await harness.init();
  await harness.selectOrCreateThread(threadId);

  // Switch mode if requested
  if (mode) {
    await harness.switchMode({ modeId: mode });
  }

  const response = await harness.sendMessage({ content: message });
  return Response.json({ text: response.text });
}

The harness preserves conversation context across mode switches — the builder agent sees what the planner discussed.

Tools & Permissions

Register tools globally (available to all modes) or per-mode. The permission system controls which tools run automatically vs. which need user approval.

src/mastra/harness.ts
import { createTool } from '@mastra/core/tools';
import { z } from 'zod';

const fileTool = createTool({
  id: 'write-file',
  description: 'Write content to a file',
  inputSchema: z.object({
    path: z.string(),
    content: z.string(),
  }),
  outputSchema: z.object({ success: z.boolean() }),
  execute: async (inputData) => {
    // In production: write to workspace filesystem
    console.log(`Writing ${inputData.path}`);
    return { success: true };
  },
});

export const harness = new Harness({
  modes: [
    { id: 'plan', agent: planAgent, default: true },
    { id: 'build', agent: buildAgent },
  ],
  tools: { fileTool },
  storage,
  memory,
});

// After init, set permission policies:
// await harness.grantSessionCategory({ category: 'read' });
// await harness.setPermissionForTool({ toolId: 'write-file', policy: 'ask' });

When a tool has policy: 'ask', the harness pauses execution and emits an approval event. Your UI presents the request, the user approves/denies, and you call respondToToolApproval().

Memory & State

The harness manages two persistence layers: memory (conversation history, semantic recall, working memory) and state (application-level data like current model, user preferences).

src/mastra/harness.ts
import { toJsonSchema } from 'zod-to-json-schema';

const stateSchema = toJsonSchema(z.object({
  currentModelId: z.string().default('openai/gpt-4o-mini'),
  projectName: z.string().default('untitled'),
  lastActivity: z.string().optional(),
}));

export const harness = new Harness({
  modes: [
    { id: 'plan', agent: planAgent, default: true },
    { id: 'build', agent: buildAgent },
  ],
  storage,
  memory,
  stateSchema,
});

// Read/write state:
// const state = await harness.getState();
// await harness.setState({ projectName: 'my-agent' });
Observational Memory: The harness can run a background observer that consolidates long message histories into dense observations. Enable it with observationalMemory: { enabled: true } in the memory config. This keeps the context window lean as conversations grow.

Human-in-the-Loop

Three interaction points where the harness pauses for human input:

  1. Tool approvals — agent wants to run a guarded tool, user approves/denies
  2. Questions — agent uses ask_user tool, user answers
  3. Plan approvals — agent submits a plan, user approves before execution
src/app/api/approve/route.ts
import { harness } from '@/mastra/harness';

export async function POST(req: Request) {
  const { type, id, decision, answer } = await req.json();

  switch (type) {
    case 'tool':
      await harness.respondToToolApproval({
        toolCallId: id,
        decision, // 'approve' | 'deny'
      });
      break;

    case 'question':
      await harness.respondToQuestion({
        questionId: id,
        answer,
      });
      break;

    case 'plan':
      await harness.respondToPlanApproval({
        planId: id,
        decision,
      });
      break;
  }

  return Response.json({ ok: true });
}

Subagents

Spawn focused agents for specific subtasks. A forked subagent clones the parent thread so it has full conversation context — and preserves prompt-cache hits.

src/mastra/harness.ts
const researchSubagent = new Agent({
  id: 'research-sub',
  name: 'Researcher',
  instructions: 'You research topics using web search. Return facts only.',
  model: 'openai/gpt-4o-mini',
  tools: { searchTool },
});

export const harness = new Harness({
  modes: [
    { id: 'plan', agent: planAgent, default: true },
    { id: 'build', agent: buildAgent },
  ],
  subagents: [
    {
      agent: researchSubagent,
      forked: true, // inherits parent conversation context
    },
  ],
  tools: { fileTool },
  storage,
  memory,
});

The main agent can delegate to a subagent by calling a tool — or your UI can explicitly dispatch to one. Forked subagent threads are hidden from listThreads() by default to keep the thread list clean.

Full Example

Putting it all together — a harness with two modes, tools with permissions, memory, state, and a subagent:

src/mastra/harness.ts
import { Harness } from '@mastra/core/harness';
import { Agent } from '@mastra/core/agent';
import { createTool } from '@mastra/core/tools';
import { Memory } from '@mastra/memory';
import { LibSQLStore } from '@mastra/libsql';
import { z } from 'zod';
import { toJsonSchema } from 'zod-to-json-schema';

// Storage
const storage = new LibSQLStore({
  url: process.env.DATABASE_URL ?? 'file:./local.db',
});

// Memory with observational memory
const memory = new Memory({
  storage,
  options: {
    lastMessages: 30,
    workingMemory: { enabled: true },
    semanticRecall: { topK: 5 },
    observationalMemory: { enabled: true, consolidateAfter: 25 },
  },
});

// Tools
const searchTool = createTool({
  id: 'web-search',
  description: 'Search the web',
  inputSchema: z.object({ query: z.string() }),
  outputSchema: z.object({ results: z.string() }),
  execute: async (inputData) => {
    const res = await fetch('https://api.tavily.com/search', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({
        query: inputData.query,
        max_results: 5,
        api_key: process.env.TAVILY_API_KEY,
      }),
    });
    const data = await res.json();
    return {
      results: (data.results ?? [])
        .map((r: { title: string; content: string }) =>
          `- ${r.title}: ${r.content?.slice(0, 200)}`)
        .join('\n'),
    };
  },
});

const fileTool = createTool({
  id: 'write-file',
  description: 'Write a file to the project',
  inputSchema: z.object({ path: z.string(), content: z.string() }),
  outputSchema: z.object({ success: z.boolean() }),
  execute: async (inputData) => {
    // workspace write in production
    return { success: true };
  },
});

// Agents
const planner = new Agent({
  id: 'plan',
  name: 'Planner',
  instructions: `You are a planning agent. Break tasks into clear steps.
Never write code directly — delegate to the builder.`,
  model: 'openai/gpt-4o-mini',
});

const builder = new Agent({
  id: 'build',
  name: 'Builder',
  instructions: `You implement code. Use write-file to create files.
Follow the plan from the planner. Be precise.`,
  model: 'openai/gpt-4o-mini',
});

const researcher = new Agent({
  id: 'research-sub',
  name: 'Researcher',
  instructions: 'Research topics. Return concise facts with sources.',
  model: 'openai/gpt-4o-mini',
  tools: { searchTool },
});

// State schema
const stateSchema = toJsonSchema(z.object({
  currentModelId: z.string().default('openai/gpt-4o-mini'),
  projectName: z.string().default('untitled'),
}));

// Harness
export const harness = new Harness({
  modes: [
    { id: 'plan', agent: planner, default: true },
    { id: 'build', agent: builder },
  ],
  subagents: [
    { agent: researcher, forked: true },
  ],
  tools: { fileTool },
  storage,
  memory,
  stateSchema,
});
src/app/api/chat/route.ts
import { harness } from '@/mastra/harness';

export async function POST(req: Request) {
  const { message, threadId, mode } = await req.json();

  await harness.init();
  await harness.selectOrCreateThread(threadId ?? 'default');

  if (mode) await harness.switchMode({ modeId: mode });

  // Grant read tools auto-approval, require ask for writes
  await harness.grantSessionCategory({ category: 'read' });
  await harness.setPermissionForTool({ toolId: 'write-file', policy: 'ask' });

  const response = await harness.sendMessage({ content: message });
  return Response.json({
    text: response.text,
    mode: harness.getCurrentModeId(),
  });
}

Next steps