Skip to main content

AgentController

beta

The AgentController feature is in beta stage and subject to breaking changes in minor versions until it graduates from its beta status.

AgentController is a shared runtime host for interactive agent applications. It coordinates modes, models, storage, workspaces, tool approvals, subagents, and channels. Each user or active task works through an isolated Session.

Mastra Code is the flagship AgentController implementation. It's a terminal-based coding agent with multi-model support, persistent conversations, and plan-then-execute workflows. Read Building a coding agent for a step-by-step guide.

When to use the AgentController
Direct link to When to use the AgentController

Use the AgentController when your application needs:

  • Multiple agent modes that share one conversation thread (e.g., plan → build → review)
  • A control layer between your UI and the agent loop (model switching, state persistence, thread management)
  • Tool approval flows and permission policies for human-in-the-loop gating
  • Subagent orchestration to delegate focused subtasks with constrained tools
  • Persistent threads and selected thread settings across restarts, with isolated live state for each Session

You could assemble all of this yourself on top of the Agent class, which exposes the full agent loop, tools, and memory. The AgentController provides opinionated defaults for an ongoing session where the agent acts as a collaborator rather than a one-shot endpoint. Reach for the Agent class directly when you want full control or a request-response call. Reach for the AgentController when you want the collaborative-session model without building the runtime around it.

Quickstart
Direct link to Quickstart

Create the backing Agent, storage, and Workspace. Call controller.init() once, then use controller.createSession() to create a Session. Subscribe with session.subscribe() and send work with session.sendMessage():

src/mastra/agent-controller.ts
import { Agent } from '@mastra/core/agent'
import { AgentController } from '@mastra/core/agent-controller'
import { LocalFilesystem, Workspace } from '@mastra/core/workspace'
import { LibSQLStore } from '@mastra/libsql'

const agent = new Agent({
id: 'assistant',
name: 'Assistant',
instructions: 'Help the user plan and complete tasks.',
model: 'openai/gpt-5.6-sol',
})

const controller = new AgentController({
id: 'assistant-controller',
agent,
storage: new LibSQLStore({
id: 'agent-controller-storage',
url: 'file:./mastra.db',
}),
workspace: new Workspace({
id: 'assistant-workspace',
filesystem: new LocalFilesystem({ basePath: './workspace' }),
}),
modes: [
{
id: 'plan',
name: 'Plan',
metadata: { default: true },
instructions: 'Reason about the task before making changes.',
},
{
id: 'build',
name: 'Build',
instructions: 'Implement the approved plan.',
},
],
})

await controller.init()

const session = await controller.createSession({
resourceId: 'user-123',
})

const unsubscribe = session.subscribe(event => {
if (event.type === 'message_update') {
console.log(event.message)
}
})

await session.sendMessage({ content: 'Plan a small TypeScript CLI.' })
unsubscribe()

Use the same controller for many Sessions. Don't store a current Session on the controller or route work through controller-level message methods.

Understand the runtime model
Direct link to Understand the runtime model

The controller, Session, and thread have different lifetimes:

  • Controller: A shared host for configuration and runtime services. Initialize it once and reuse it.
  • Session: An isolated live runtime for one user, task, or concurrent work scope. It owns the active mode, model, state, event bus, run state, grants, and current thread binding.
  • Thread: A stored conversation containing messages and thread settings. Threads can survive controller and process recreation when you configure storage.

A Session is live state. Arbitrary session.state, permission grants, pending approvals, and active runs don't automatically survive process recreation. Thread messages and selected thread settings, including mode and per-mode model choices, can persist through storage.

Sessions and threads
Direct link to Sessions and threads

createSession() is get-or-create by resourceId and optional scope:

const webSession = await controller.createSession({
resourceId: 'user-123',
scope: 'web',
})

const sameWebSession = await controller.createSession({
resourceId: 'user-123',
scope: 'web',
})

const workerSession = await controller.createSession({
resourceId: 'user-123',
scope: 'background-worker',
})

console.log(webSession === sameWebSession) // true
console.log(webSession === workerSession) // false

Sessions with different scopes have separate event buses, run loops, state, mode and model selections, and current thread bindings. Their stored threads still belong to the shared resourceId.

Pass threadId when the host must bind the Session to an exact thread. The controller switches to the existing thread or creates it with that ID when it's missing. This behavior also applies when createSession() returns a cached Session:

const session = await controller.createSession({
resourceId: 'user-123',
scope: 'web',
threadId: 'support-ticket-42',
})

Use session.thread.create() and session.thread.switch() to move one live Session between conversations.

Switch modes and models
Direct link to Switch modes and models

Modes change the instructions and tools used by the shared backing agent without replacing the Session or thread. Configure mode-specific tools and visibility on the controller:

const modes = [
{
id: 'plan',
name: 'Plan',
metadata: { default: true },
instructions: 'Investigate the task and propose a plan.',
additionalTools: { searchDocs },
availableTools: ['searchDocs', 'submit_plan'],
transitionsTo: 'build',
},
{
id: 'build',
name: 'Build',
instructions: 'Implement the approved plan.',
},
]

tools and additionalTools are mutually exclusive inputs for adding mode-specific tools. When the controller has a shared backing agent, either input layers those tools onto the agent's tools. Use availableTools to restrict the final exposed tool names for a mode. Permission denies still take precedence over this allowlist.

Switch the live Session with session.mode.switch(). Read the active mode with session.mode.get() or session.mode.resolve():

await session.mode.switch({ modeId: 'build' })

console.log(session.mode.get()) // "build"
console.log(session.mode.resolve().instructions)

Switch models independently with session.model.switch(), then read the active selection with session.model.get(). Thread-scoped selections are stored per mode and restored when the Session returns to that mode:

await session.model.switch({
modelId: 'anthropic/claude-sonnet-4-6',
scope: 'thread',
})

console.log(session.model.get())

Use scope: 'global' for an in-memory selection that shouldn't be written to thread settings.

Manage threads and state
Direct link to Manage threads and state

List stored conversations with session.thread.list():

const thread = await session.thread.create({ title: 'Release planning' })
const threads = await session.thread.list()

await session.thread.switch({ threadId: thread.id })
console.log(threads.length)

Use session.state for structured live state associated with the Session. Read it with session.state.get() and write updates with session.state.set(). Define stateSchema and initialState on the controller when you need validation and defaults:

console.log(session.state.get())

await session.state.set({ activeProject: 'docs-site' })

session.state.get() returns a snapshot. set() validates and merges the update into the Session state. Treat this state as live Session data unless your host explicitly persists and restores it.

Approve tools and resume suspensions
Direct link to Approve tools and resume suspensions

Permission policies decide whether a tool is allowed, denied, or sent to the UI for approval. Map custom tools to categories with toolCategoryResolver on the controller:

const controller = new AgentController({
toolCategoryResolver: toolName => {
if (toolName === 'delete_project') return 'execute'
return null
},
})

Configure category policies with session.permissions.setForCategory() and tool policies with session.permissions.setForTool():

await session.permissions.setForCategory({
category: 'execute',
policy: 'ask',
})

await session.permissions.setForTool({
toolName: 'delete_project',
policy: 'deny',
})

When a policy resolves to ask, subscribe for the approval event and return the user's decision with session.respondToToolApproval():

session.subscribe(event => {
if (event.type === 'tool_approval_required') {
session.respondToToolApproval({
toolCallId: event.toolCallId,
decision: 'approve',
})
}
})

The always_allow_category decision grants the tool category for the rest of the live Session. Session grants aren't durable process-level permissions.

Interactive tools such as ask_user and submit_plan use resumable tool suspensions instead. Resume them with session.respondToToolSuspension():

session.subscribe(event => {
if (event.type === 'tool_suspended' && event.toolName === 'ask_user') {
void session.respondToToolSuspension({
toolCallId: event.toolCallId,
resumeData: 'Use SQLite.',
})
}
})

For submit_plan, resume with { action: 'approved' } or { action: 'rejected', feedback }. An approved plan can switch to the mode configured by transitionsTo before the run continues.

Delegate to subagents
Direct link to Delegate to subagents

Configure available subagent types on the controller. The built-in subagent tool can then delegate focused tasks using those definitions:

const controller = new AgentController({
tools: {
searchDocs,
},
subagents: [
{
id: 'code-reviewer',
name: 'Code reviewer',
description: 'Review a change for correctness and regressions.',
instructions: 'Inspect the change and report actionable findings.',
allowedControllerTools: ['searchDocs'],
allowedWorkspaceTools: ['view', 'find_files'],
defaultModelId: 'openai/gpt-5-mini',
},
],
})

A regular subagent starts with its configured instructions and constrained toolset. Set forked: true when the child should clone the parent thread and run with the parent agent's instructions and tools. Forked subagents preserve the parent prompt prefix, ignore the definition's instructions, tools, allowlists, and default model, and require memory on the controller.

Use session.subagents.model.set() to store one default subagent model or a model for a specific agent type. Read the selection with session.subagents.model.get():

await session.subagents.model.set({
modelId: 'openai/gpt-5-mini',
})

await session.subagents.model.set({
agentType: 'code-reviewer',
modelId: 'anthropic/claude-sonnet-4-6',
})

const reviewerModel = session.subagents.model.get({
agentType: 'code-reviewer',
})

These selections are written to thread settings. An agent-type selection takes precedence over the Session's default subagent model.

Connect chat channels
Direct link to Connect chat channels

Pass channel adapters to the controller and register it on a Mastra instance:

src/mastra/index.ts
import { Mastra } from '@mastra/core'
import { AgentController } from '@mastra/core/agent-controller'
import { createSlackAdapter } from '@chat-adapter/slack'

const controller = new AgentController({
id: 'support-controller',
agent,
storage,
workspace,
modes,
channels: {
adapters: {
slack: createSlackAdapter(),
},
resolveResourceId: ({ thread, message, defaultResourceId }) => {
if (thread.isDM) return message.author.userId
return defaultResourceId
},
},
})

export const mastra = new Mastra({
agentControllers: { controller },
storage,
})

Point each platform webhook at the controller-specific route:

/api/agent-controllers/<CONTROLLER_ID>/channels/<PLATFORM>/webhook

Each external chat thread maps to one controller Session and Mastra thread. By default, new sessions use a resource ID derived from the adapter's chat-thread ID, prefixed with channel:. Use resolveResourceId to map direct messages to an existing application user or choose another memory owner. The callback only affects new threads; an existing thread keeps its stored resource ID.

Controller channel sessions and auto-approval state are held in memory, so use a long-lived server. Pending approvals and live Session state don't survive process restarts. Adapters that can't render approval controls automatically run tools without an approval prompt so the run doesn't remain suspended.

See Channels for adapter setup and platform-specific webhook configuration.

Connect a UI
Direct link to Connect a UI

Subscribe to Session events for incremental updates. Read the reduced display state with session.displayState.get() when the UI needs a complete render snapshot:

const unsubscribe = session.subscribe(event => {
if (event.type === 'display_state_changed') {
render(event.displayState)
}
})

render(session.displayState.get())

// Call when the UI disconnects.
unsubscribe()

Subscriptions are isolated by Session. Events from another Session on the same controller aren't delivered to this listener. Read the Building a coding agent guide for a complete TUI example.