AgentController class
The AgentController feature is in beta stage and subject to breaking changes in minor versions until it graduates from its beta status.
The AgentController class orchestrates multiple agent modes, shared state, memory, and storage. It provides a control layer that a TUI or other UI can drive to manage threads, switch models and modes, send messages, handle tool approvals, and track events.
Per-conversation state — identity, the active thread, mode and model selection, run state, grants, and the display snapshot — lives on the Session, accessed through agentController.session.
For a conceptual introduction, see the AgentController overview.
Usage exampleDirect link to Usage example
Import the AgentController class and create a new instance with your agent, storage backend, and modes:
import { AgentController } from '@mastra/core/agent-controller'
import { LibSQLStore } from '@mastra/libsql'
import { z } from 'zod'
const agentController = new AgentController({
id: 'my-coding-agent',
agent: myAgent,
storage: new LibSQLStore({ url: 'file:./data.db' }),
stateSchema: z.object({
currentModelId: z.string().optional(),
}),
modes: [
{
id: 'plan',
name: 'Plan',
metadata: { default: true },
instructions: 'Reason about changes before making them.',
},
{ id: 'build', name: 'Build', transitionsTo: 'plan' },
],
})
agentController.subscribe(event => {
if (event.type === 'message_update') {
renderMessage(event.message)
}
})
await agentController.init()
await agentController.selectOrCreateThread()
await agentController.sendMessage({ content: 'Hello!' })
Constructor parametersDirect link to Constructor parameters
id:
resourceId?:
storage?:
stateSchema?:
initialState?:
memory?:
modes:
id:
name?:
default?:
defaultModelId?:
description?:
instructions?:
transitionsTo?:
metadata?:
tools?:
additionalTools?:
availableTools?:
agent?:
agent?:
defaultModeId?:
instructions?:
tools?:
workspace?:
browser?:
subagents?:
id:
name:
description:
instructions:
tools?:
allowedAgentControllerTools?:
allowedWorkspaceTools?:
defaultModelId?:
maxSteps?:
stopWhen?:
forked?:
resolveModel?:
omConfig?:
disableBuiltinTools?:
heartbeatHandlers?:
idGenerator?:
modelAuthChecker?:
modelUseCountProvider?:
modelUseCountTracker?:
customModelCatalogProvider?:
gateways?:
toolCategoryResolver?:
pubsub?:
threadLock?:
observability?:
PropertiesDirect link to Properties
id:
session:
MethodsDirect link to Methods
LifecycleDirect link to Lifecycle
init()Direct link to init
Initialize the agentController. Loads storage, initializes a static workspace (dynamic factory workspaces are resolved per-session during createSession), propagates memory and workspace to mode agents, and starts heartbeat handlers. Call this before using the agentController.
await agentController.init()
createSession({ id, ownerId, resourceId?, tags?, workspace?, browser?, requestContext? })Direct link to createsession-id-ownerid-resourceid-tags-workspace-browser-requestcontext-
Create a new, fully-wired Session and bring it online. The session starts in the default mode with the seeded model, connects to the AgentController's shared machinery (agent, storage/lock, config catalog), and has a current thread (the most recent thread for the resource, or a freshly created one). Call init() once before creating sessions so shared storage is ready.
The AgentController owns no session of its own — every consumer creates its own session and drives all work through it. In a server or multiplayer setting, each request, thread, or user gets its own session, isolated from every other: independent event bus, mode, model, state, and current thread.
id and ownerId are required — they mirror SessionRecord.id and SessionRecord.ownerId and are stable for the life of the session. resourceId is optional and defaults to config.resourceId then config.id.
Each session owns its own Workspace and Browser instance. When workspace is omitted, the AgentController resolves its configured workspace (a static instance or a dynamic factory) and passes it to the session. Pass a workspace override to give a specific session a different workspace than the AgentController default. The workspace is initialized during session creation; workspace_ready and workspace_status_changed events are emitted on the session bus after init() completes, and late subscribers receive a replay of the last workspace status.
const session = await agentController.createSession({
id: 'session-xyz',
ownerId: 'user-123',
})
You can also override the resource:
const session = await agentController.createSession({
id: 'session-xyz',
ownerId: 'user-123',
resourceId: 'project-abc',
})
Override the workspace and browser for a specific session:
const session = await agentController.createSession({
id: 'session-xyz',
ownerId: 'user-123',
workspace: myWorkspace,
browser: myBrowser,
})
tags scopes initial thread selection: a thread is a resume candidate only when its metadata matches every provided tag. This lets worktrees sharing a resourceId each resume their own thread (via a projectPath tag).
Switching the resource ID via agentController.setResourceId() changes only the resource ID, not id or ownerId. Read them through session.identity.getId() and session.identity.getOwnerId().
selectOrCreateThread()Direct link to selectorcreatethread
Select the most recent thread for the current resource, or create one if none exist. Loads thread metadata and acquires a thread lock.
const thread = await agentController.selectOrCreateThread()
destroy()Direct link to destroy
Stop all heartbeat handlers and clean up resources.
await agentController.destroy()
removeHeartbeat({ id })Direct link to removeheartbeat-id-
Remove a specific heartbeat handler by ID. Calls the handler's shutdown() callback if defined.
await agentController.removeHeartbeat({ id: 'gateway-sync' })
stopHeartbeats()Direct link to stopheartbeats
Stop and remove all heartbeat handlers.
await agentController.stopHeartbeats()
getCurrentAgent()Direct link to getcurrentagent
Return the fully-configured Agent for the current mode with runtime services (storage, memory, workspace, pubsub, telemetry) propagated.
const agent = agentController.getCurrentAgent()
getResolvedMemory()Direct link to getresolvedmemory
Return the resolved memory instance, or null if no memory is configured.
const memory = await agentController.getResolvedMemory()
getMastra()Direct link to getmastra
Return the internal Mastra instance, or undefined before init(). Useful for scorer registration, observability access, and eval tooling.
const mastra = agentController.getMastra()
getWorkspace()Direct link to getworkspace
Return the AgentController-level workspace if it is a static Workspace instance. Dynamic factory workspaces are not resolved here — use resolveWorkspace() to resolve a factory against a session's request context.
const workspace = agentController.getWorkspace()
resolveWorkspace({ session, requestContext? })Direct link to resolveworkspace-session-requestcontext-
Eagerly resolve and cache the workspace. For dynamic workspaces (factory function), this triggers the factory against the given session's request context and caches the result so getWorkspace() returns it. Returns the resolved workspace or undefined if none is configured.
const workspace = await agentController.resolveWorkspace({ session })
// With an explicit request context
const requestContext = new RequestContext()
const workspace = await agentController.resolveWorkspace({ session, requestContext })
hasWorkspace()Direct link to hasworkspace
Whether a workspace is configured on this AgentController (static instance or dynamic factory). Sessions without an explicit workspace override fall back to this.
if (agentController.hasWorkspace()) {
// ...
}
isWorkspaceReady()Direct link to isworkspaceready
Whether the AgentController-level static workspace has been initialized. Dynamic factory workspaces are resolved and initialized per-session during createSession, so this returns false for factory configs until a session is created.
if (agentController.isWorkspaceReady()) {
// ...
}
ModesDirect link to Modes
listModes()Direct link to listmodes
Return all configured AgentControllerMode instances.
const modes = agentController.listModes()
To read the active mode, use session.mode.get(); to resolve it to a full AgentControllerMode, use session.mode.resolve().
ModelsDirect link to Models
To read the active model ID, use session.model.get(); for a short display name, use session.model.displayName(); to check whether a model is selected, use session.model.hasSelection().
getCurrentModelAuthStatus()Direct link to getcurrentmodelauthstatus
Check if the current model's provider has authentication configured. Uses modelAuthChecker if provided, falling back to environment variable checks from the provider registry.
const status = await agentController.getCurrentModelAuthStatus()
// { hasAuth: true, apiKeyEnvVar: 'ANTHROPIC_API_KEY' }
listAvailableModels()Direct link to listavailablemodels
Retrieve all available models from the provider registry, including their authentication status and use counts.
const models = await agentController.listAvailableModels()
// [{ id, provider, modelName, hasApiKey, apiKeyEnvVar, useCount }]
ThreadsDirect link to Threads
The agentController owns thread lifecycle transitions — creating, switching, cloning, renaming, and deleting threads — because they coordinate the shared thread lock and emit events. The active thread binding and thread/message reads live on session.thread.
createThread({ title? })Direct link to createthread-title-
Create a new thread. Initializes thread metadata, saves it to storage, acquires a thread lock, and emits a thread_created event.
const thread = await agentController.createThread({ title: 'New conversation' })
switchThread({ threadId })Direct link to switchthread-threadid-
Switch to a different thread. Aborts any in-progress operations, acquires a lock on the new thread, releases the lock on the previous thread, loads the thread's metadata, and emits a thread_changed event.
await agentController.switchThread({ threadId: 'thread-abc123' })
To list threads from storage, use session.thread.list(). By default it returns only threads for the current resource and hides transient forked subagent threads; pass includeForkedSubagents: true to opt back into seeing them — e.g. for a debug panel.
renameThread({ title })Direct link to renamethread-title-
Update the title of the current thread.
await agentController.renameThread({ title: 'Updated title' })
cloneThread({ sourceThreadId?, title?, resourceId? })Direct link to clonethread-sourcethreadid-title-resourceid-
Clone an existing thread and switch to the clone. Copies all messages, acquires a lock on the new thread, releases the lock on the previous thread, and emits a thread_created event. If sourceThreadId is omitted, the current thread is cloned. When Observational Memory is enabled, OM records are cloned with remapped message IDs.
// Clone the current thread
const cloned = await agentController.cloneThread()
// Clone a specific thread with a custom title
const cloned = await agentController.cloneThread({
sourceThreadId: 'thread-abc123',
title: 'Alternative approach',
})
See Memory.cloneThread() for details on what gets cloned.
To read the current resource ID, use session.identity.getResourceId().
setResourceId({ resourceId })Direct link to setresourceid-resourceid-
Set the resource ID and clear the current thread.
agentController.setResourceId({ resourceId: 'project-xyz' })
getKnownResourceIds()Direct link to getknownresourceids
Return the distinct resource IDs that have threads in storage.
const resourceIds = await agentController.getKnownResourceIds()
getSession()Direct link to getsession
Return current session information including thread ID, mode ID, and the list of threads.
const session = await agentController.getSession()
// { currentThreadId, currentModeId, threads }
MessagesDirect link to Messages
sendMessage({ content, files?, requestContext? })Direct link to sendmessage-content-files-requestcontext-
Send a message to the current agent. Creates a thread if none exists, builds a RequestContext and toolsets, and streams the agent's response. Handles tool calls, approvals, and errors automatically. If you provide requestContext, the agentController forwards it to tools and subagents during the run.
await agentController.sendMessage({ content: 'Explain the authentication flow' })
Reading messages is owned by session.thread: use listActiveMessages() for the active thread, listMessages({ threadId }) for a specific thread, and firstUserMessage({ threadId }) / firstUserMessages({ threadIds }) for thread previews.
MemoryDirect link to Memory
The memory property bundles thread management operations into a single namespace. memory.createThread, memory.switchThread, and memory.renameThread delegate to the corresponding AgentController lifecycle methods documented above; memory.listThreads delegates to session.thread.list().
memory.deleteThread({ threadId })Direct link to memorydeletethread-threadid-
Delete a thread and all its messages from storage. If the deleted thread is the currently active thread, the thread lock is released and the agentController clears its active thread. Emits a thread_deleted event.
await agentController.memory.deleteThread({ threadId: 'thread-abc123' })
Flow controlDirect link to Flow control
abort()Direct link to abort
Abort any in-progress generation.
agentController.abort()
steer({ content, requestContext? })Direct link to steer-content-requestcontext-
Steer the agent mid-stream by injecting an instruction into the current generation.
agentController.steer({ content: 'Focus on security implications' })
followUp({ content, requestContext? })Direct link to followup-content-requestcontext-
Queue a follow-up message to be sent after the current generation completes. If no operation is running, sends the message immediately.
agentController.followUp({ content: 'Now apply those changes' })
Tool approvalsDirect link to Tool approvals
Responding to a pending tool approval is owned by the session — see session.respondToToolApproval(). The agentController owns the permission policy that decides when approval is required, documented under Permissions below.
Tool suspensions and plansDirect link to Tool suspensions and plans
respondToToolSuspension({ resumeData, toolCallId?, requestContext? })Direct link to respondtotoolsuspension-resumedata-toolcallid-requestcontext-
Respond to a pending tool suspension. Interactive built-in tools such as ask_user and request_access pause through the native tool-suspension primitive, which emits a tool_suspended event carrying toolCallId, toolName, and suspendPayload. Pass resumeData to resume the suspended tool with the user's response.
Provide toolCallId to select which suspension to resume. It is required when more than one tool is suspended at the same time (for example, parallel ask_user calls). When omitted, it resolves to the sole pending suspension.
agentController.subscribe(event => {
if (event.type === 'tool_suspended' && event.toolName === 'ask_user') {
const { question } = event.suspendPayload as { question: string }
// Show `question` to the user, then resume the tool with their answer.
agentController.respondToToolSuspension({
toolCallId: event.toolCallId,
resumeData: 'Yes, proceed with the refactor',
})
}
})
For multi-select questions, pass the selected option labels as a string array.
agentController.respondToToolSuspension({
toolCallId: event.toolCallId,
resumeData: ['Add tests', 'Update docs'],
})
Responding to a submitted planDirect link to Responding to a submitted plan
The submit_plan built-in tool pauses via the native tool-suspension primitive, so it surfaces through the same tool_suspended event as other interactive tools. Resume it with respondToToolSuspension, passing a resumeData object with action ('approved' or 'rejected') and an optional feedback string.
On approval, the AgentController automatically switches to its default (execution) mode. On rejection, the plan-mode run resumes so the agent can revise and submit again.
agentController.respondToToolSuspension({
toolCallId: event.toolCallId,
resumeData: { action: 'approved' },
})
agentController.respondToToolSuspension({
toolCallId: event.toolCallId,
resumeData: { action: 'rejected', feedback: 'Needs more detail' },
})
PermissionsDirect link to Permissions
getToolCategory({ toolName })Direct link to gettoolcategory-toolname-
Resolve a tool's category using the configured toolCategoryResolver.
const category = agentController.getToolCategory({ toolName: 'mastra_workspace_write_file' })
// 'edit'
Observational MemoryDirect link to Observational Memory
loadOMProgress()Direct link to loadomprogress
Load observational memory records for the current thread and emit an om_status event with reconstructed progress.
await agentController.loadOMProgress()
getObservationalMemoryRecord()Direct link to getobservationalmemoryrecord
Return the full ObservationalMemoryRecord for the current thread and resource, or null if no thread is selected or no record exists.
const record = await agentController.getObservationalMemoryRecord()
if (record) {
console.log(record.activeObservations)
console.log(record.generationCount)
console.log(record.observationTokenCount)
}
The observer/reflector model selection and observation/reflection thresholds live on the Session under session.om, grouped by role. Read them with session.om.observer.modelId() / session.om.reflector.modelId() and session.om.observer.threshold() / session.om.reflector.threshold(), and change a role's model with session.om.observer.switchModel() / session.om.reflector.switchModel().
Forked subagentsDirect link to Forked subagents
By default, a subagent runs with a fresh context — it doesn't see the parent conversation. Forked subagents opt into a different model: the subagent runs on a clone of the parent thread and reuses the parent agent's full configuration. This is useful when the subagent needs the full context of the conversation so far (e.g., recalling earlier user-supplied facts), and when prompt-cache hit rates matter.
Enabling forked modeDirect link to Enabling forked mode
Set forked: true either on the AgentControllerSubagent definition (per-type default) or on each subagent tool call (per-invocation override):
// Per-type default — every call to this subagent forks unless overridden.
const subagents: AgentControllerSubagent[] = [
{
id: 'collaborator',
name: 'Collaborator',
description: 'Continues the conversation in a fork to try a different angle.',
instructions: '...',
forked: true,
},
]
The model can also pass forked: true (or forked: false) per-invocation in the subagent tool input; the per-invocation value wins.
Semantics and constraintsDirect link to Semantics and constraints
- Memory required. Forked mode calls
memory.cloneThreadto create the fork, so the agentController must havememoryconfigured and an active parent thread. Calls without those return a structured error rather than throwing. - Parent agent reused. The fork runs through the parent agent's
stream(...)call. The parent's instructions, tools, model,maxSteps, andstopWhenapply. The subagent definition'sinstructions,tools,allowedAgentControllerTools,allowedWorkspaceTools,defaultModelId,maxSteps, andstopWhenare ignored in forked mode — this is what preserves the prompt-cache prefix. - Toolsets inherited, recursive forks blocked at runtime. Forks inherit the parent's toolsets verbatim (
ask_user,submit_plan, user-configured agentController tools, including thesubagenttool itself) so the LLM request prefix — system prompt + tool list + tool schemas + tool descriptions — stays byte-identical to the parent's. This is what preserves the prompt cache. Thesubagententry is kept on the model side but itsexecuteis replaced inside the fork with a stub that returns a non-error "tool unavailable inside a forked subagent" message: nested forks are blocked at the runtime layer without perturbing the cached prefix. - Fork threads are tagged. Each fork thread is created with
metadata.forkedSubagent === trueandmetadata.parentThreadId === <parent>. By default,session.thread.list()hides these so they don't show up in user-facing thread pickers / startup flows. PassincludeForkedSubagents: trueto see them in admin / debug tooling. - Save-queue flushed before clone. The agent stream batches message saves through a debounced
SaveQueueManager, so the parent's latest user / assistant turn may not be on disk yet when the subagent tool call fires. The fork tool flushes pending saves first via theflushMessagescallback onAgentToolExecutionContextbefore cloning, so the fork actually carries the latest turn. Flush failures are non-fatal — the clone still runs. - Parent thread untouched. All subagent activity (messages, OM writes) lands on the fork. The parent thread is never appended to during a forked subagent run.
When to prefer non-forked modeDirect link to When to prefer non-forked mode
Forked mode trades isolation for context inheritance. If the subagent should run with a strictly smaller toolset, a different system prompt, or a cheaper model, use the default (non-forked) mode and pass any required context explicitly in the task description.
EventsDirect link to Events
subscribe(listener)Direct link to subscribelistener
Register an event listener. Returns an unsubscribe function.
Use this method for all consumers — UI, Server-Sent Events (SSE), terminal UI (TUI), bridge rendering, audit logs, debugging, analytics, and deterministic replay. For display rendering, watch for the display_state_changed event and read the latest snapshot from session.displayState.get(). After every event the agentController emits display_state_changed, so high-frequency events such as message_update, tool_update, and tool_input_delta are coalesced into the next snapshot.
// Render from the coalesced display-state snapshot:
const unsubscribe = agentController.subscribe(event => {
if (event.type === 'display_state_changed') {
render(agentController.session.displayState.get())
}
})
// Render an initial frame before the next agentController event:
render(agentController.session.displayState.get())
To handle raw events directly, switch on event.type:
const unsubscribe = agentController.subscribe(event => {
switch (event.type) {
case 'message_update':
renderMessage(event.message)
break
case 'tool_approval_required':
showApprovalPrompt(event.toolName)
break
case 'error':
console.error(event.error)
break
}
})
// Later:
unsubscribe()
EventsDirect link to Events
The agentController emits events through registered listeners. The following table lists the available event types:
| Event type | Description |
|---|---|
mode_changed | The active mode changed. |
model_changed | The active model changed. |
thread_changed | The active thread changed. |
thread_created | A new thread was created. |
thread_deleted | A thread was deleted. |
state_changed | AgentController state was updated. |
agent_start | The agent started processing. |
agent_end | The agent finished processing. |
message_start | A new message started streaming. |
message_update | A message was updated with new content. |
message_end | A message finished streaming. |
tool_start | A tool call started. |
tool_approval_required | A tool call requires user approval. |
tool_suspended | A tool paused via the native tool-suspension primitive (for example ask_user, request_access, or submit_plan). Includes toolCallId, toolName, and suspendPayload. Resume it with respondToToolSuspension. |
tool_update | A tool call was updated with progress. |
tool_end | A tool call finished. |
tool_input_start | Tool input started streaming. |
tool_input_delta | Tool input received a streaming delta. |
tool_input_end | Tool input finished streaming. |
usage_update | Token usage was updated. |
error | An error occurred. |
info | An informational message was emitted. |
system_reminder | A system reminder was injected into the conversation. |
state_signal | A state signal was emitted (state-driven prompt injection). |
reactive_signal | A reactive signal fired in response to a state change. |
notification | A notification was delivered to the thread inbox. |
notification_summary | A summary of pending notifications was emitted. |
follow_up_queued | A follow-up message was queued. |
workspace_status_changed | The workspace status changed. |
workspace_ready | The workspace finished initializing. |
workspace_error | The workspace encountered an error. |
om_status | Observational Memory status update. |
om_activation | Observational Memory was activated for the thread. |
om_model_changed | An Observational Memory role model changed (observer or reflector). |
om_observation_start | An observation started. |
om_observation_end | An observation completed. |
om_observation_failed | An observation failed. |
om_reflection_start | A reflection started. |
om_reflection_end | A reflection completed. |
om_reflection_failed | A reflection failed. |
om_buffering_start | Observational Memory started buffering messages. |
om_buffering_end | Observational Memory finished buffering messages. |
om_buffering_failed | Observational Memory buffering failed. |
om_thread_title_updated | Observational Memory updated the thread title. |
subagent_start | A subagent started processing. |
subagent_text_delta | A subagent emitted a text delta. |
subagent_tool_start | A subagent started a tool call. |
subagent_tool_end | A subagent finished a tool call. |
subagent_end | A subagent finished processing. |
subagent_model_changed | A subagent's model changed. |
task_updated | A task list was updated. |
goal_evaluation | A goal evaluation completed (when native agent goals are configured). |
shell_output | A tool emitted shell output (stdout or stderr). |
display_state_changed | The canonical AgentControllerDisplayState snapshot changed. Read it from session.displayState.get(). |
The agentController also emits low-level streaming content chunks — text, thinking, tool_call, tool_result, image, and file. These are the raw pieces that get assembled into messages; most UIs render from message_update (or read the session.displayState snapshot) rather than subscribing to them directly.
Built-in toolsDirect link to Built-in tools
The agentController provides built-in tools to agents in every mode:
| Tool | Description |
|---|---|
ask_user | Ask the user a question and wait for their response. Supports free text, single-select choices, and multi-select choices. |
submit_plan | Submit a plan for user review and approval. |
task_write | Create or replace a structured task list for tracking progress. Assigns task IDs when omitted and returns the structured task list snapshot. |
task_update | Update one tracked task by ID and return the structured task list snapshot. |
task_complete | Mark one tracked task completed by ID and return the structured task list snapshot. |
task_check | Check the completion status of the current task list and return tasks, summary, incompleteTasks, and isError fields. |
subagent | Spawn a focused subagent with constrained tools (only available when subagents is configured). Pass forked: true to inherit the parent conversation — see Forked subagents. |
ask_user selectionsDirect link to ask_user-selections
The ask_user tool accepts options for choice prompts. Set selectionMode to single_select to let the user pick one option, or multi_select to let the user pick multiple options. When options are provided and selectionMode is omitted, the prompt defaults to single_select. Omit options for free-text questions.
The following example demonstrates a multi-select response handler. The tool pauses through the tool_suspended event, the UI reads selectionMode from event.suspendPayload, lets the user choose multiple options, then returns a string array with respondToToolSuspension().
agentController.subscribe(event => {
if (event.type === 'tool_suspended' && event.toolName === 'ask_user') {
const { selectionMode } = event.suspendPayload as { selectionMode?: string }
if (selectionMode === 'multi_select') {
agentController.respondToToolSuspension({
toolCallId: event.toolCallId,
resumeData: ['Add tests', 'Update docs'],
})
}
}
})