Background tasks
Added in: @mastra/core@1.29.0
Background tasks let an agent dispatch a long-running tool call without blocking the agentic loop. The tool returns an immediate acknowledgement while the LLM continues responding. The task then runs to completion in the background. When it finishes, its result is written to memory and the agent runs a follow-up turn to report it. With the untilIdle option, that turn streams on the same call; otherwise the agent is notified on the thread, as described in Report a result after the stream ends.
When to use background tasksDirect link to When to use background tasks
Use background tasks when a tool call may take long enough that the user shouldn't wait for it before seeing a response. Common cases:
- Subagent delegations that themselves run multi-step research or writing.
- Tool calls that hit slow external services, queues, or large data jobs.
- Workflows triggered from a tool call that may take minutes to complete.
For tool calls that return quickly, foreground execution using agent.stream() and agent.generate() is simpler.
Background tasks require a configured storage backend on the Mastra instance. Tasks are persisted so they survive process restarts.
QuickstartDirect link to Quickstart
Background tasks are off by default. Enable them by setting backgroundTasks.enabled on the Mastra instance:
import { Mastra } from '@mastra/core'
import { LibSQLStore } from '@mastra/libsql'
export const mastra = new Mastra({
storage: new LibSQLStore({ id: 'storage', url: 'file:mastra.db' }),
backgroundTasks: {
enabled: true,
globalConcurrency: 10,
perAgentConcurrency: 5,
backpressure: 'queue',
defaultTimeoutMs: 300_000,
},
})
The full set of options is listed in the backgroundTasks configuration reference.
Run a tool in the backgroundDirect link to Run a tool in the background
Enabling the manager doesn't run anything in the background by itself. Tools become eligible at one of two layers:
- Tool-level config: the tool itself declares it as background-eligible.
- Agent-level config: the agent declares which of its tools are background-eligible.
Eligible tools default to deferred execution. Set defaultDisposition: 'foreground' at either layer when eligibility should only give the LLM the option to run a call in the background. The LLM can include a _background field in the tool arguments to select foreground, deferred, or awaited execution for a specific call and override its timeout or retries.
Tool-levelDirect link to Tool-level
Set background.enabled: true on the tool definition. Tools opted in at this layer are eligible for background execution when called by an agent that has the manager enabled. Their configured default disposition determines whether each call runs inline or in the background unless the call includes a _background override.
import { createTool } from '@mastra/core/tools'
import { z } from 'zod'
export const researchTool = createTool({
id: 'research',
description: 'Run a long research job',
inputSchema: z.object({ topic: z.string() }),
background: {
enabled: true,
defaultDisposition: 'deferred',
timeoutMs: 600_000,
maxRetries: 1,
},
execute: async ({ topic }) => {
// Run the research job for topic
},
})
Agent-levelDirect link to Agent-level
Use backgroundTasks.tools on the agent to opt in specific tools or override timeouts for individual tools, or alternatively run all background-eligible tools in the background. Use disabled: true to short-circuit background dispatch for the agent entirely.
import { Agent } from '@mastra/core/agent'
export const researcher = new Agent({
id: 'researcher',
instructions: 'You research topics and answer questions.',
model: 'openai/gpt-6.1-sol',
tools: { researchTool, summarizeTool },
backgroundTasks: {
tools: {
researchTool: { enabled: true, timeoutMs: 600_000 },
summarizeTool: false,
},
},
})
Set tools: 'all' to opt in every tool the agent has.
LLM per-call overrideDirect link to LLM per-call override
When a tool is registered on an agent that has background tasks enabled, the model can include a _background field in the tool arguments to override the resolved configuration for that specific call. The model only includes what it wants to override, all fields in _background are optional. The override is stripped from the arguments before the tool runs.
{
"topic": "solana",
"_background": { "disposition": "awaited", "timeoutMs": 900000 }
}
The available dispositions are:
foreground: Run the tool synchronously without background-work lifecycle signals.deferred: Dispatch the tool and let the agent continue. The task remains attached to the run, and streams usinguntilIdlewait for it to reconcile.awaited: Dispatch the tool through the background task manager, but hold the current branch until its authoritative result has been reconciled.
For compatibility, _background.enabled: true selects deferred, and _background.enabled: false selects foreground. An explicit disposition takes precedence over enabled.
The _background override only modifies tools the developer has already opted in at the tool or agent layer. If a tool hasn't been opted in, a model-selected background disposition is ignored and the tool runs in the foreground. This keeps deterministic, foreground-only tools (calculators, lookups, schema validators) from being silently dispatched as tasks.
Resolution orderDirect link to Resolution order
When a tool call is dispatched, agent-level and tool-level settings determine eligibility and fallback values. For an eligible tool, the LLM _background fields override the corresponding values for that call. Manager defaults fill timeout and retry values that remain unset. An _background field can't enable a tool that's not eligible.
If the agent has backgroundTasks.disabled: true, every tool call runs synchronously regardless of the layers above.
Background tasks related stream chunksDirect link to Background tasks related stream chunks
When a tool call dispatches as a background task, two streams may surface lifecycle events for it: the agent's own stream and the backgroundTaskManager.stream() SSE stream. Each stream covers a different set of chunk types:
| Chunk type | When it fires | Emitted by |
|---|---|---|
background-task-started | The task has been enqueued and assigned a taskId. | Agent stream |
background-task-running | The task picked up a worker and started executing. | Manager stream |
background-task-progress | Shows number of running background tasks. | Agent stream |
background-task-output | A streamed output chunk from the task's execute. | Manager stream |
background-task-completed | The task finished successfully. The payload.result matches the eventual tool result. | Manager stream |
background-task-failed | The task threw or timed out. | Manager stream |
background-task-cancelled | The task was cancelled before completing. | Manager stream |
background-task-suspended | The tool called suspend() from inside its execute. | Manager stream |
background-task-resumed | A suspended task was resumed via manager.resume(taskId, resumeData). | Manager stream |
agent.stream().fullStream only emits the agent-loop chunks (background-task-started, background-task-progress) on its own. agent.stream() with untilIdle: true emits the same two chunks and additionally subscribes to the manager pubsub for the run's memory scope and pipes the seven manager chunks (background-task-running, background-task-output, background-task-completed, background-task-failed, background-task-cancelled, background-task-suspended, background-task-resumed) into the same fullStream.
backgroundTaskManager.stream() only emits the seven manager chunks.
The full payload shapes are documented in the background task chunks reference.
Keep the agent stream open with untilIdleDirect link to keep-the-agent-stream-open-with-untilidle
agent.stream() returns once the LLM emits a final response even if a background task is still running. Pass untilIdle: true when you want the stream to stay open until every dispatched background task has completed and the LLM has had a chance to respond to the result:
const stream = await agent.stream('Research solana for me', {
memory: { thread: 't1', resource: 'u1' },
untilIdle: true,
})
for await (const chunk of stream.fullStream) {
// chunks from the initial turn AND any continuation turns triggered by
// background task completions flow through here
}
When a background task completes, the result is injected into the agent memory, stream() re-enters the agentic loop so the LLM can react to it. The stream closes when no tasks are running and no completions are queued.
Customize the idle timeout by passing an object instead of true. The timer only runs while the wrapper is between turns, so a slow first token won't close the stream. The default is 5 minutes:
const stream = await agent.stream('Research solana for me', {
memory: { thread: 't1', resource: 'u1' },
untilIdle: { maxIdleMs: 30_000 },
})
Visit Agent.stream() for the full API.
To change the default for every untilIdle: true call, including calls Mastra makes internally, set untilIdleMaxIdleMs on the Mastra backgroundTasks config. Use a value longer than your slowest background task so its completion still triggers a continuation turn. A per-call maxIdleMs still takes precedence:
export const mastra = new Mastra({
backgroundTasks: {
enabled: true,
untilIdleMaxIdleMs: 30 * 60_000,
},
})
Report a result after the stream endsDirect link to Report a result after the stream ends
When a deferred task finishes and no untilIdle stream is open for its thread, Mastra sends the agent a notification signal with the tag background-task. The signal names the tool call and task, and tells the agent the result is now on the tool call. It doesn't copy the result into the prompt. This covers a stream() call without untilIdle, a client that disconnected, and a task resumed after it suspended.
- If the thread is idle, the signal wakes the agent, which runs a turn on the thread to report the outcome. The woken run uses the request context of the run that dispatched the task, so a model, instructions, or memory resolved from that context resolve the same way.
- If a run is already active on the thread, including the run that dispatched the task, the signal is delivered to that run's next model request. If that run ends without making another request, the signal runs as its own turn.
- If another agent's run holds the thread, for example while it waits for a tool approval, the signal waits and wakes the agent once that run ends.
- If an
untilIdlestream is open for the thread, no signal is sent, because that stream runs its own follow-up turn. - If the caller cancelled or aborted the
untilIdlestream that was waiting for the task, no signal is sent either: stopping the stream means the caller doesn't want a reply for that work. A task that suspended and is answered later isn't covered by this, because the stream stopped waiting for it when it suspended.
A run that a signal starts on an idle thread, such as a message sent with agent.sendSignal(), stays open until the tasks it dispatched finish, and reports them itself. To end woken runs after their own turn instead, set the temporary wokenRunsEndAfterTurn option. Their tasks then report back with this signal when they finish:
import { Mastra } from '@mastra/core'
export const mastra = new Mastra({
backgroundTasks: {
enabled: true,
wokenRunsEndAfterTurn: true,
},
})
A signal's own ifIdle.streamOptions.untilIdle takes precedence over the option in both directions. In the next major release, ending after the turn becomes the only behavior and wokenRunsEndAfterTurn is removed. To keep a woken run open after that, pass untilIdle: true in ifIdle.streamOptions. Agent controller sessions and channels already pass it.
If you combine wokenRunsEndAfterTurn with notifyAgentOnFinish: false, nothing reports the tasks of a woken run, so your app has to send the completion itself.
If your app sends its own completion signal, for example from onTaskComplete, set notifyAgentOnFinish: false on the Mastra backgroundTasks config so the agent isn't told twice:
import { Mastra } from '@mastra/core'
export const mastra = new Mastra({
backgroundTasks: {
enabled: true,
notifyAgentOnFinish: false,
onTaskComplete: async task => {
// Send your own notification to task.threadId
},
},
})
The signal needs memory with a thread and resource. A task dispatched without them only writes its result to the task record. Awaited tasks don't send the signal, because the run that dispatched them waits for the result.
Aggregate propertiesDirect link to Aggregate properties
stream() with untilIdle returns a MastraModelOutput that looks like the one from a regular stream() call, but fullStream alone spans the initial turn and any auto-continuations. Aggregate properties (text, toolCalls, toolResults, finishReason, messageList, getFullOutput()) still resolve against the first turn's internal buffer. If you need an aggregate view across continuations, consume fullStream yourself and accumulate.
Subagents in the backgroundDirect link to Subagents in the background
Subagent invocations are dispatched as tool calls under the hood, so the same background configuration applies. The recommended pattern is to opt each subagent in on the supervisor, it's clearer and lets you tune timeoutMs per subagent in one place:
import { Agent } from '@mastra/core/agent'
const supervisor = new Agent({
id: 'supervisor',
instructions: 'Coordinate research and writing using the available agents.',
model: 'openai/gpt-6.1-sol',
agents: { researchAgent, writingAgent },
backgroundTasks: {
tools: {
researchAgent: { enabled: true, timeoutMs: 900_000 },
writingAgent: { enabled: true, timeoutMs: 900_000 },
},
},
})
const stream = await supervisor.stream('Research AI in education and write an article', {
memory: { thread: 't1', resource: 'u1' },
untilIdle: true,
})
Inheriting from the subagentDirect link to Inheriting from the subagent
If a subagent isn't listed under the supervisor's backgroundTasks.tools but has background-eligible tools of its own (either via tool-level background.enabled: true or its own backgroundTasks.tools entry) the framework still dispatches the entire subagent invocation as a background task. The supervisor inherits the subagent's intent: the subagent itself becomes the background task, and it can dispatch its eligible ordinary tools in the background inside its loop. Further delegated-agent calls run in the foreground.
The background config used for the inherited dispatch (for example waitTimeoutMs) is derived from the subagent's own backgroundTasks config.
import { Agent } from '@mastra/core/agent'
const researchAgent = new Agent({
id: 'research-agent',
description: 'Gathers factual information.',
model: 'openai/gpt-6-luna',
tools: { deepResearchTool },
backgroundTasks: {
tools: {
deepResearchTool: { enabled: true, timeoutMs: 600_000 },
},
waitTimeoutMs: 900_000,
},
})
When this researchAgent is delegated to from a supervisor that has no background task configuration for the researchAgent, the supervisor still dispatches the whole researchAgent invocation as a background task.
Mastra supports one nested background-tool level inside a delegated run: a root agent can delegate to a subagent, and that subagent can dispatch an eligible ordinary tool in the background. A second delegated-agent edge runs in the foreground and doesn't receive nested background guidance. This limit is execution-scoped and doesn't mutate the shared subagent configuration.
Which layer to use depends on where consistency matters: subagent-level configuration travels with the agent, which keeps its background behavior the same under every supervisor, whereas the supervisor-side opt-in above centralizes that tuning in one place. This boundary stops at a single nested delegation level and has no workflow integration.
Suspending and resumingDirect link to Suspending and resuming
A background task can pause itself mid-execution and wait for an external signal before continuing. This is useful for human approvals, webhooks, or any flow where the next step depends on data that arrives later.
A tool calls suspend(data) from inside its execute, which:
- Persists
status: 'suspended'and thedatapayload on the task record. - Saves the workflow snapshot so the run survives process restarts.
- Emits a
background-task-suspendedchunk on the manager stream. - Releases the concurrency slot so other tasks can run.
Resume the task with mastra.backgroundTaskManager.resume(taskId, resumeData). The resumeData arrives in the tool's execute options on the resumed run, and the task transitions back to running.
import { createTool } from '@mastra/core/tools'
import { z } from 'zod'
export const reviewTool = createTool({
id: 'review',
description: 'Submit a draft for human review.',
inputSchema: z.object({ draft: z.string() }),
outputSchema: z.object({ approvedBy: z.string(), edits: z.string().optional() }),
background: { enabled: true },
execute: async ({ draft }, context) => {
const { suspend, resumeData } = context.agent
if (!resumeData) {
await suspend?.({ awaiting: 'approval', draft })
return { approvedBy: '', edits: undefined }
}
const { reviewer, edits } = resumeData as { reviewer: string; edits?: string }
return { approvedBy: reviewer, edits }
},
})
The first invocation of execute sees resumeData === undefined and calls suspend. After the task is resumed, the runtime restarts the tool with resumeData populated. The if condition is false. The tool returns its real result.
To resume the task once an approval arrives:
await mastra.backgroundTaskManager?.resume(taskId, {
reviewer: 'alice@example.com',
edits: 'Reworded paragraph 3.',
})
On the resumed run, context.agent.suspendPayload holds the data the tool suspended with. Use it to tell which request an answer belongs to without encoding that in resumeData.
Answer a suspended task from a clientDirect link to Answer a suspended task from a client
While an agent's background tool call is suspended, the assistant message that holds the call records the open request under metadata.suspendedTools[toolCallId]. The entry carries toolName, args, suspendPayload, taskId, runId, and suspendedAt, so a chat that reloads the thread can still show the request. The entry is removed when the task resumes or is cancelled.
The entry is written by the process that dispatched the tool call, the same process that writes the task's result into the thread. A task that a worker picks up after a restart has no such process, so neither the open request nor the result reaches the thread.
To answer the request over HTTP, call POST /api/background-tasks/:backgroundTaskId/resume with the answer in resumeData. The client SDK wraps the route as resumeBackgroundTask():
import { MastraClient } from '@mastra/client-js'
const client = new MastraClient({ baseUrl: 'http://localhost:4111' })
const task = await client.getBackgroundTask(taskId)
await client.resumeBackgroundTask(
taskId,
{ reviewer: 'alice@example.com' },
{ suspendedAt: task.suspendedAt },
)
Pass the suspendedAt you read from the task or the suspendedTools entry. When the task has resumed or suspended again since, the server returns 409 Conflict instead of delivering a stale answer. The route also returns 409 when the task isn't suspended or no concurrency slot is free, and 404 when the task doesn't exist or belongs to another resource. Calling it requires the background-tasks:execute permission.
Resume when no slot is freeDirect link to Resume when no slot is free
By default, resume() throws Concurrency limit reached when every concurrency slot is in use. Pass { onNoSlot: 'queue' } to queue the resume instead:
const task = await mastra.backgroundTaskManager?.resume(taskId, resumeData, { onNoSlot: 'queue' })
// task.status is still 'suspended' until a slot frees up
The task stays suspended until a slot frees up, then resumes with the queued resumeData. Cancelling the task drops the queued resume.
The queue lives in the memory of the process that called resume(). If that process exits before the resume runs, the task stays suspended and the resumeData is lost. To survive restarts, persist the resume data yourself and call resume() again.
What happens to the agent loopDirect link to What happens to the agent loop
When a task suspends mid-stream() with untilIdle, the wrapper treats it as terminal for the current iteration and closes. Once the resume payload is available, call agent.resumeStream(resumeData, { runId, toolCallId, memory, untilIdle: true }) to continue immediately. The resumed background task completes and adds its result to the message list before the agent runs a follow-up turn on the same SSE connection. To drive the resume out of band, call mastra.backgroundTaskManager.resume(taskId, resumeData) directly, or use the resume route. When the task finishes, its result is written into the thread and the agent wakes to report it.
Re-registering the executor on resumeDirect link to Re-registering the executor on resume
The manager keeps tool executors in process memory. If the process restarts while a task is suspended, the executor closure is gone, the caller of resume() must re-register it first via manager.registerTaskContext(taskId, ...). Tasks dispatched and resumed inside the same process don't need this.
Cancelling a suspended taskDirect link to Cancelling a suspended task
manager.cancel(taskId) works against suspended tasks the same way it works for running ones. The row changes to cancelled and the workflow snapshot is cleaned up. A task.cancelled event then fires.
Lifecycle callbacksDirect link to Lifecycle callbacks
Each layer can register terminal-state callbacks. They don't replace one another, and success/failure hooks fire for their outcomes:
- Tool-level
background.onComplete/onFailed: scoped to one tool. - Agent-level
backgroundTasks.onTaskComplete/onTaskFailed: scoped to all tasks dispatched by this agent. - Manager-level
onTaskComplete/onTaskFailed: scoped globally. These fire for every task, including tasks from non-agent owners.
import { Mastra } from '@mastra/core'
export const mastra = new Mastra({
storage,
backgroundTasks: {
enabled: true,
onTaskComplete: task => {
logger.info('Background task complete', { taskId: task.id, toolName: task.toolName })
},
onTaskFailed: task => {
logger.error('Background task failed', { taskId: task.id, error: task.error })
},
},
})
StreamingDirect link to Streaming
Subscribe to all task eventsDirect link to Subscribe to all task events
Pass allResources: true to receive every task event in the system, across all resources. On connection, the stream emits a snapshot of all currently running tasks, then forwards live events as they happen.
const bgManager = mastra.backgroundTaskManager;
if (!bgManager) throw new Error('Background tasks are not enabled');
const controller = new AbortController();
const stream = bgManager.stream({ allResources: true, abortSignal: controller.signal });
for await (const chunk of stream) {
switch (chunk.type) {
case 'background-task-running':
console.log('started', chunk.payload.taskId, chunk.payload.toolName);
break;
case 'background-task-completed':
console.log('done', chunk.payload.taskId, chunk.payload.result);
break;
case 'background-task-failed':
console.error('failed', chunk.payload.taskId, chunk.payload.error);
break;
}
}
The stream stays open until the caller's AbortSignal fires. Always pass an abortSignal so you can disconnect cleanly.
allResources: true delivers task arguments and results for every user. Use it only in trusted server-side code, never to serve a single user's request. Without resourceId or allResources, stream() only delivers tasks that have no resourceId. Agent tasks dispatched with memory always have a resourceId, so they won't appear in that stream.
Filter the streamDirect link to Filter the stream
Pass any combination of filter options to narrow the events you receive. Filters apply to both the initial snapshot and the live event subscription.
const stream = bgManager.stream({
agentId: 'researcher',
threadId: 't1',
resourceId: 'u1',
abortSignal: controller.signal,
})
| Filter | Description |
|---|---|
agentId | Only events from tasks dispatched by this agent |
runId | Only events from this specific agent run |
threadId | Only events from tasks scoped to this memory thread |
resourceId | Only events from tasks scoped to this resource |
taskId | Only events for a single task |
allResources | Include tasks from every resource when resourceId is omitted. Trusted server-side code only |
abortSignal | Closes the stream when the signal aborts |
Look up task state directlyDirect link to Look up task state directly
For one-off lookups instead of a live stream, use getTask and listTasks:
const task = await mastra.backgroundTaskManager?.getTask(taskId)
const { tasks, total } = await mastra.backgroundTaskManager?.listTasks({
status: 'running',
agentId: 'researcher',
})
These read from storage rather than the pubsub stream, so they're suitable for paginated lists and detail views.
Non-agent ownersDirect link to Non-agent owners
Code other than agents can run tasks through the manager. For example, an MCP server can run long tool calls as background tasks. The task's agentId is its owner key, and agent streams, untilIdle, and the end-of-step wait only see tasks whose agentId matches the agent. Use a namespaced owner key such as mcp-server:<serverId> so it can't collide with a real agent ID. Mastra doesn't enforce this.
Non-agent owners usually register a static executor with registerStaticExecutor() so any process can run or recover their tasks. They can also set these optional task fields:
expiresAt: When cleanup deletes the task. Once a task completes, fails, is cancelled, or times out, cleanup deletes it afterexpiresAtpasses instead of usingcompletedTtlMsorfailedTtlMs. Cleanup only runs when you set thecleanupoption, and it never deletes a pending, running, or suspended task, so cancel expired work first. It also needs a storage adapter version that supportsexpiresAt. Older adapters keep using the global retention settings.metadata: JSON stored with the task. The manager doesn't read it.
mastra.backgroundTaskManager?.registerStaticExecutor('mcp-server:ops:deploy', {
execute: runDeploy,
})
await mastra.backgroundTaskManager?.enqueue({
agentId: 'mcp-server:ops',
toolName: 'mcp-server:ops:deploy',
toolCallId: taskId,
runId: taskId,
args,
resourceId: principalId,
expiresAt: new Date(Date.now() + 24 * 60 * 60 * 1000),
metadata: { ttlMs: 86_400_000 },
})
Use perOwnerConcurrency to give an owner its own concurrency limit instead of perAgentConcurrency. Its tasks still count toward globalConcurrency:
export const mastra = new Mastra({
storage,
backgroundTasks: {
enabled: true,
globalConcurrency: 60,
perOwnerConcurrency: { 'mcp-server:ops': 50 },
},
})
Known limitations:
- A recovered task runs without the original request. Store anything the executor needs, such as the principal in
resourceIdor other JSON inmetadata, on the task itself. - A
pendingtask that isn't pinned holds no lease. On startup, another process can pick it up and dispatch it. If its executor only existed in the original process, the task fails withNo executor registered. Static executors registered in every process avoid this. UsepinToWorkerwhen the original process already started the work. - The end-of-step wait in the agent loop only waits for
runningtasks, notpendingones. #28228 tracks the fix.
Pin a task to the current workerDirect link to Pin a task to the current worker
Sometimes work for a task is already running in this process before you enqueue it. For example, an MCP server may start a tool call inside the request and hand it to a background task only if it runs long. Pass pinToWorker: true with a task context so no other process starts the same work:
await mastra.backgroundTaskManager?.enqueue(
{
agentId: 'mcp-server:ops',
toolName: 'mcp-server:ops:deploy',
toolCallId: taskId,
runId: taskId,
args,
},
{ executor: { execute: adoptRunningDeploy } },
{ pinToWorker: true },
)
pinToWorker means work for this task is already in flight on this worker. The pending task records this worker as its owner and holds an execution lease, which the worker renews while the task waits for a free slot. Other processes don't recover or dispatch the task while the lease is live.
If the worker dies or shuts down first, that work is lost. Recovery treats the task as a lost attempt, like a running task whose worker died. If retryCount is below maxRetries, the task uses a retry and runs on another worker, which needs a static executor for it. Otherwise the task fails with Worker process terminated before task completed. A task with maxRetries: 0 never runs on another worker. Other processes check for lapsed pins at startup and then every leaseDurationMs, unless recoverStaleTasksOnStart is false.
A lapsed lease only shows that the worker stopped renewing it, not that the work stopped. A worker whose event loop stalls past the lease can still be running the task when another worker retries it. Make pinned work idempotent, or guard its side effects, if it can be retried.
pinToWorker throws without a task context. Tasks that aren't pinned, including all agent tasks, hold no lease while pending.