Channels
Added in: @mastra/core@1.22.0
Channels connect agents to messaging platforms. Configure them via the channels property on the Agent constructor. The object you pass is a ChannelConfig. See the Channels overview for concepts and platform setup instructions.
Usage exampleDirect link to Usage example
import { Agent } from '@mastra/core/agent'
import { createSlackAdapter } from '@chat-adapter/slack'
import { createDiscordAdapter } from '@chat-adapter/discord'
export const supportAgent = new Agent({
id: 'support-agent',
name: 'Support Agent',
instructions: 'You are a helpful support assistant.',
model: 'openai/gpt-6.1-sol',
channels: {
adapters: {
slack: createSlackAdapter(),
discord: createDiscordAdapter(),
},
},
})
ParametersDirect link to Parameters
The channels property accepts a ChannelConfig object with the following fields:
adapters:
slack, discord). Pass an Adapter directly for defaults, or a ChannelAdapterConfig object to customize per-adapter options.handlers?:
inlineMedia?:
inlineLinks?:
tools?:
getTools() returns the channel-specific tools (add_reaction, remove_reaction). Set to false for models that do not support function calling. Channel tools are never added to the agent automatically — pass them explicitly via tools: { ...channels.getTools() }. They work on any run in a channel-backed thread, including runs that did not start from a chat message, such as a schedule or a Studio prompt.state?:
MastraStateAdapter backed by the Mastra instance storage. Channels require storage to be configured.userName?:
name, or 'Mastra' if no name is set.threadContext?:
maxMessages sets how many platform messages are saved to memory on first mention: the thread's first message plus up to maxMessages - 1 recent messages, with an omitted-count marker between them when needed (set to 0 to disable; only applies to non-DM threads). addSystemMessage: false skips the built-in system message that tells the agent which channel/platform a request came from.chatOptions?:
dedupeTtlMs, fallbackStreamingPlaceholderText, lockScope, and messageHistory.resolveResourceId?:
resourceId owns resource-level memory for a channel thread, separately from who sent the message. Runs only when a new thread is created; reused threads keep their stored owner and never call the hook. Return ctx.defaultResourceId (${platform}:${message.author.userId}) to keep the built-in behavior.resolveThreadId?:
resolveResourceId with the resolved owner on the context, and only when a new thread is created; reused threads keep their stored id and never call the hook. The returned id must be unique across the memory store — on collision a generated id is used instead. Return ctx.defaultThreadId (a random UUID) to keep the built-in behavior.waitUntil?:
waitUntil function. Required on Vercel so background agent runs survive after the webhook returns 200. On Vercel pass waitUntil from @vercel/functions. Cloudflare Workers and Netlify Functions are detected automatically from the request context. AWS Lambda does not need waitUntil because it waits for the event loop to drain naturally.resolveWaitUntil?:
waitUntil lives on the Hono request context but is not covered by the built-in helper. Resolution order: bare waitUntil → resolveWaitUntil(c) → default (Cloudflare Workers, Netlify).Per-adapter optionsDirect link to Per-adapter options
Wrap an adapter in a ChannelAdapterConfig object to set per-adapter options:
import { Agent } from '@mastra/core/agent'
import { createDiscordAdapter } from '@chat-adapter/discord'
import { createSlackAdapter } from '@chat-adapter/slack'
const agent = new Agent({
id: 'example',
name: 'Example',
instructions: '...',
model: 'openai/gpt-6.1-sol',
channels: {
adapters: {
discord: {
adapter: createDiscordAdapter(),
toolDisplay: 'text',
cors: {
origin: ['https://customer-saas.example'],
credentials: true,
},
gateway: false,
},
slack: createSlackAdapter(), // Plain adapter uses defaults
},
},
})
adapter:
gateway?:
false for serverless deployments that only need webhook-based interactions.cards?:
toolDisplay instead. When toolDisplay is not set, cards: true maps to toolDisplay: "cards" and cards: false maps to toolDisplay: "text". IDEs flag the field with a strikethrough; runtime behavior is preserved.cors?:
formatError?:
context.requestContext is the failed run's request context, so the message can link back to whatever the run belongs to.formatToolCall?:
toolDisplay (function form) instead. When set, runs as a ToolDisplayFn that only fires on result/error events; running and approval events fall through to no render. Mutually exclusive with toolDisplay at the type level.streaming?:
true; other adapters default to false.textFormat?:
'markdown' (the default) posts replies as markdown: adapters with native markdown rendering (Slack) render it directly, others convert it to their platform format. 'plain' posts replies as literal plain text, restoring the pre-markdown behavior for agents prompted to emit a platform dialect such as Slack mrkdwn. Applies to final reply text only; tool cards, error messages, and tripwire notices are unaffected. Native streaming is always markdown regardless of this setting.textDisplay?:
'hidden' never posts reply text, so the agent's own messages reach the channel only through tools you give it, such as a reply tool. Tool display, approval prompts, error messages, and typing status are unaffected, and the text still reaches the agent's stream and memory.toolDisplay?:
"cards" posts per-tool running/result cards as rich Block Kit. "text" posts the same lifecycle as plain text (no Block Kit). "timeline" and "grouped" stream tool state as inline task_update chunks (requires streaming: true; Slack only today — other adapters may render a placeholder). "hidden" executes tools silently. Pass a function to render tool events yourself; return { kind: "post", message } for a discrete post/edit, { kind: "stream", chunk } to push into the streaming widget, or undefined to skip rendering that event. Add openIfEmpty: false to a stream result when its chunk should only apply to an active streaming session. Approve/deny prompts render as a separate built-in card in every string mode; a toolDisplay function receives the approval event and may replace the card by returning { kind: "post", message }.toolApprovers?:
'anyone' to allow another channel member to respond even when a requester was recorded. Pending-approval and thread-mapping checks still apply.typingStatus?:
true uses built-in defaults (is typing… on text, is calling {tool}… on tool-call, is waiting for approval… on tool-call-approval). false suppresses typing entirely — useful when a live streaming widget (e.g. toolDisplay: "grouped" in Slack) already conveys progress. Pass a function to set custom status copy per chunk; return a string to set the status, or false/null/undefined to leave it unchanged. Compose with defaultTypingStatus (exported from @mastra/core/channels) to fall back to defaults for chunks you don't handle.Tool display modesDirect link to Tool display modes
toolDisplay controls how tool calls render in chat. The default 'cards'
posts a "Running…" card per tool and edits it with the result, matching the
behavior in earlier versions. 'text' is the same lifecycle but without rich
Block Kit, useful for platforms that don't render cards well.
'timeline' and 'grouped' stream tool state as inline task_update chunks
alongside the agent's text. These modes require streaming: true and rely on
the chat adapter to render the chunks. Slack supports both natively; other
adapters may render a placeholder until they add support. If streaming is
disabled, the channel logs a one-time warning and falls back to 'cards'.
'hidden' executes tools silently. Only the typing status indicates work in
progress.
Pass a function to toolDisplay for fully custom rendering. The function
receives a ToolDisplayEvent (running / result / error / approval /
approved / denied) and a ToolDisplayContext ({ mode, platform });
return { kind: 'post', message } for a discrete post/edit, { kind: 'stream', chunk } to push into the active streaming widget, or undefined to skip
rendering that event.
After a user selects Approve or Deny, Mastra sends an approved or denied
event. Both include byUser, the name of the user who responded, which is
undefined in DMs. A post result replaces the approval card in place. If the
function returns nothing, a blank message, or throws, Mastra shows the default
"Approved" or "Denied" card, which names the user outside DMs.
By default, a stream result opens a streaming session when none is active. Set
openIfEmpty: false when the chunk only applies to an existing session. Mastra
skips the chunk when no session is active. Static channels ignore this option
and keep their existing plain-text fallback behavior.
toolDisplay: event => {
if (event.kind !== 'running') return undefined
return {
kind: 'stream',
chunk: {
type: 'task_update',
id: event.toolCallId,
title: event.displayName,
status: 'in_progress',
},
openIfEmpty: false,
}
}
Approve/deny prompts (requireApproval) render as a separate built-in card in
every string mode, because inline task entries can't carry interactive buttons.
With a toolDisplay function, the approval event is passed to your function
in both streaming and static modes; return a non-blank { kind: 'post', message }
to replace the built-in card with your own message (for example, a localized
one). Returning undefined, a blank message, or { kind: 'stream' } falls back
to the built-in card so the user can still approve or deny the request. Keep the
button ids in the format the default action handler expects. See Custom
approval cards.
import { Agent } from '@mastra/core/agent'
import { createSlackAdapter } from '@chat-adapter/slack'
const agent = new Agent({
id: 'streaming-agent',
name: 'Streaming Agent',
instructions: '...',
model: 'openai/gpt-6.1-sol',
channels: {
adapters: {
slack: {
adapter: createSlackAdapter(),
streaming: true, // already the Slack default
toolDisplay: 'timeline',
},
},
},
})
Custom approval cardsDirect link to Custom approval cards
A custom approval message replaces the built-in card, but Mastra's default
onAction handler still handles the button action. It only resumes the suspended run
when the button ids are exactly:
- Approve:
tool_approve:<toolCallId> - Deny:
tool_deny:<toolCallId>
<toolCallId> is event.toolCallId from the approval event. Any other id,
including one with extra whitespace, doesn't match a pending approval. The run
stays suspended. The default handler must also stay enabled: don't set
handlers.onAction: false, and if you provide your own onAction, call
defaultHandler() so approval clicks are processed.
A custom toolDisplay function replaces the built-in renderer for every tool
event. The example below only renders approvals, so other tool calls post
nothing; return a message for result and error events as well if you want
those visible.
import { Agent } from '@mastra/core/agent'
import { createSlackAdapter } from '@chat-adapter/slack'
const agent = new Agent({
id: 'localized-approval-agent',
name: 'Localized Approval Agent',
instructions: '...',
model: 'openai/gpt-6.1-sol',
channels: {
adapters: {
slack: {
adapter: createSlackAdapter(),
toolDisplay: event => {
if (event.kind !== 'approval') return undefined
return {
kind: 'post',
message: {
type: 'card',
title: `Approve ${event.displayName}?`,
children: [
{
type: 'actions',
children: [
{
type: 'button',
id: `tool_approve:${event.toolCallId}`,
label: 'Genehmigen',
style: 'primary',
},
{
type: 'button',
id: `tool_deny:${event.toolCallId}`,
label: 'Ablehnen',
style: 'danger',
},
],
},
],
},
}
},
},
},
},
})
Custom typing statusDirect link to Custom typing status
Pass a function to typingStatus to customize the status copy. The function is
called once per stream chunk; return a string to set the status, or false /
null / undefined to leave the current status unchanged. Return values are
de-duplicated so the platform only sees a call when the status changes. When
the run ends, any status it set is cleared automatically so runs that finish
without posting a message don't leave a stale status on the thread.
defaultTypingStatus is exported from @mastra/core/channels so you can
fall back to the built-in defaults for chunks you don't handle.
import { Agent } from '@mastra/core/agent'
import { defaultTypingStatus } from '@mastra/core/channels'
import { createDiscordAdapter } from '@chat-adapter/discord'
const agent = new Agent({
id: 'custom-typing-agent',
name: 'Custom Typing Agent',
instructions: '...',
model: 'openai/gpt-6.1-sol',
channels: {
adapters: {
discord: {
adapter: createDiscordAdapter(),
typingStatus: (chunk, ctx) => {
if (chunk.type === 'tool-call' && chunk.payload.toolName === 'searchDocs') {
return 'is searching docs…'
}
return defaultTypingStatus(chunk, ctx)
},
},
},
},
})
HandlersDirect link to Handlers
Override built-in event handlers. Each handler can be:
- Omitted: uses the default Mastra handler (routes the message through the agent and posts the response), except
onMessage, which is disabled by default false: disables the handler entirely- A function
(thread, message, defaultHandler, ctx) => Promise<void>: wraps or replaces the default - When a
chatOptions.concurrencystrategy batches messages from several senders into one dispatch, the function is called once per sender's turn, each with its ownctx;ctx.skippedholds that turn's earlier batched messages
import { Agent } from '@mastra/core/agent'
import { createSlackAdapter } from '@chat-adapter/slack'
const agent = new Agent({
id: 'custom-handler-agent',
name: 'Custom Handler Agent',
instructions: '...',
model: 'openai/gpt-6.1-sol',
channels: {
adapters: {
slack: createSlackAdapter(),
},
handlers: {
onMention: async (thread, message, defaultHandler) => {
console.log('Received mention:', message.text)
await defaultHandler(thread, message)
},
onDirectMessage: false,
},
},
})
onDirectMessage?:
onMention?:
onMessage?:
onMention unless it is also false.onSubscribedMessage?:
To answer without an @mention, supply onMessage. It receives every message the bot can read that hasn't already been routed to a direct-message, mention, or subscribed-thread handler. Filter by channel before calling defaultHandler if only some channels should receive replies:
import type { ChannelHandlers } from '@mastra/core/channels'
const handlers: ChannelHandlers = {
onMessage: async (thread, message, defaultHandler) => {
if (thread.channelId !== 'slack:C_SUPPORT') return
await defaultHandler(thread, message)
},
}
defaultHandler runs the agent, posts the response, and subscribes the thread. Later messages in that thread go to onSubscribedMessage. Runs started through onMessage report channel.eventType as message. When a queued batch mixes mentioned and unmentioned senders, unmentioned senders go to onMessage when it's set. Otherwise they go to onMention, or are skipped if onMention is false.
The ChannelHandler function signature:
type ChannelHandler = (
thread: Thread,
message: Message,
defaultHandler: (thread: Thread, message: Message) => Promise<void>,
ctx: ChannelHandlerContext,
) => Promise<void>
type ChannelHandlerContext = {
mastra?: Mastra
requestContext: RequestContext
readonly signalMetadata: Record<string, unknown>
readonly skipped: readonly Message[]
}
ctx.mastra is the resolved mastra instance, so a handler can reach storage or other registered primitives without being passed an external accessor:
onDirectMessage: async (thread, message, defaultHandler, ctx) => {
const store = await ctx.mastra?.getStorage()?.getStore('memory')
await defaultHandler(thread, message)
}
ctx.requestContext is the RequestContext for the run this message is about to start, built fresh for every message. Write to it before calling defaultHandler and the value reaches the run, alongside the channel entries Mastra adds afterward:
onDirectMessage: async (thread, message, defaultHandler, ctx) => {
ctx.requestContext.set('locale', 'en-GB')
await defaultHandler(thread, message)
}
Anything the run reads from its request context can be decided per message this way, such as which user a platform sender maps to.
ctx.signalMetadata is the metadata attached to this message's Agent Signal, built fresh for every message. Add JSON-serializable, non-sensitive values before calling defaultHandler. Mastra may persist signal metadata and publish it through PubSub. Unlike ctx.requestContext, it follows the message whether it starts a new run or is delivered to one that's already active.
ctx.skipped lists earlier messages from the same sender that the Chat SDK batched into this turn when a chatOptions.concurrency strategy such as burst, debounce, or queue is set, oldest first. It's empty when nothing was batched.
Resource ID resolutionDirect link to Resource ID resolution
By default a channel thread's memory resourceId is ${platform}:${message.author.userId}. The sender owns the memory, scoped per platform. For apps with a shared identity, such as single sign-on (SSO), this splits memory: the same user gets feishu:user_123 in a Feishu DM but user_123 on the web.
Pass resolveResourceId to decide memory ownership separately from the sender. It runs only when a new thread is created. Reused threads keep their stored resourceId and never call the hook, so existing conversations don't depend on the resolver being available. Return ctx.defaultResourceId to fall back to the built-in behavior.
import { Agent } from '@mastra/core/agent'
import { createSlackAdapter } from '@chat-adapter/slack'
const agent = new Agent({
id: 'sso-agent',
name: 'SSO Agent',
instructions: '...',
model: 'openai/gpt-6.1-sol',
channels: {
adapters: {
slack: createSlackAdapter(),
},
resolveResourceId: async ({ thread, message }) => {
// DM: share resource-level memory with the web app by using the bare SSO id
if (thread.isDM) {
return await resolveSsoUserId(message)
}
// Group chat: the conversation owns the memory; the sender stays the actor
return thread.channelId
},
},
})
The ResolveResourceIdContext passed to the function:
platform:
slack, discord).thread:
thread.isDM to tell DMs apart from group/channel threads.message:
message.author.userId is the actor/sender, not necessarily the memory owner.defaultResourceId:
${platform}:${message.author.userId}). Return this to keep the current behavior.requestContext:
defaultHandler (for example a tenant id) are readable here, so you don't need to look them up again.Thread ID resolutionDirect link to Thread ID resolution
By default a new channel thread gets a random UUID as its internal Mastra thread id. Pass resolveThreadId to pick the id yourself: for example, give the thread the same id as its session to match the names your app assigns to threads it creates.
The hook runs after resolveResourceId, so the resolved owner is available on the context. Like resolveResourceId it runs only when a new thread is created: reused threads keep their stored id and never call the hook. The returned id must be unique across the memory store. If it already belongs to an existing thread, Mastra logs a warning and uses a generated id instead so the existing thread is never overwritten. Return ctx.defaultThreadId to keep the built-in behavior.
import { Agent } from '@mastra/core/agent'
import { createSlackAdapter } from '@chat-adapter/slack'
const agent = new Agent({
id: 'session-agent',
name: 'Session Agent',
instructions: '...',
model: 'openai/gpt-6.1-sol',
channels: {
adapters: {
slack: createSlackAdapter(),
},
// Owner: a session id resolved from the sender's linked account
resolveResourceId: async ctx => resolveSessionId(ctx),
// Thread id: align with the session id so app URLs that address
// threads by session id resolve channel-created threads too
resolveThreadId: ({ resourceId, defaultThreadId }) => {
return isSessionId(resourceId) ? resourceId : defaultThreadId
},
},
})
The ResolveThreadIdContext passed to the function:
platform:
slack, discord).thread:
thread.isDM to tell DMs apart from group/channel threads.message:
resourceId:
resourceId the new thread will belong to (after resolveResourceId).defaultThreadId:
requestContext:
resolveResourceId.Inline mediaDirect link to Inline media
Controls which attachment types (images, video, PDFs, etc.) are sent as file parts to the model. Types that don't match are described as text summaries so the agent knows about the file without crashing models that reject unsupported types.
The default (['image/png', 'image/jpeg', 'image/webp', 'application/pdf']) matches the formats supported by major vision models. Override inlineMedia to expand the list (e.g. ['image/*', 'audio/*']) or replace it entirely with a predicate function.
Supported glob patterns:
| Pattern | Matches |
|---|---|
image/* | All image types (image/png, image/jpeg, etc.) |
video/* | All video types |
* or */* | All types |
application/pdf | Exact type match |
For platforms with private CDNs (e.g. Slack), attachments are fetched with authenticated credentials from the Chat SDK. For platforms with public CDNs (e.g. Discord), the URL is passed directly to the model.
Inline linksDirect link to Inline links
Promotes URLs found in message text to file parts so the model can process linked content instead of seeing raw URL text. Each entry can be a string (domain pattern) or an object with a forced mime type.
String entries match a domain and perform a HEAD request to detect the Content-Type. The resolved type is checked against inlineMedia and only matching types become file parts.
Object entries match a domain and force a specific mime type, skipping the HEAD request and bypassing the inlineMedia check. This is useful for sites like YouTube where a HEAD request returns text/html, but the model treats the URL as video content.
type InlineLinkEntry =
| string // Domain pattern (HEAD determines mime type)
| { match: string; mimeType: string } // Domain + forced mime type (skips HEAD)
RelatedDirect link to Related
- Channels overview: Concepts, quickstart, and platform setup
- Agent class: Constructor parameters and methods
- Chat SDK adapters: Adapter configuration and platform setup