# Channels

**Added in:** `@mastra/core@1.22.0`

Channels connect agents to messaging platforms. Configure them via the `channels` property on the `Agent` constructor. See the [Channels guide](https://mastra.ai/docs/agents/channels) for concepts and platform setup instructions.

## Usage example

```typescript
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-5.5',
  channels: {
    adapters: {
      slack: createSlackAdapter(),
      discord: createDiscordAdapter(),
    },
  },
})
```

## Parameters

**adapters** (`Record<string, Adapter | ChannelAdapterConfig>`): Platform adapters keyed by name (e.g. \`slack\`, \`discord\`). Pass an \`Adapter\` directly for defaults, or a \`ChannelAdapterConfig\` object to customize per-adapter options.

**handlers** (`ChannelHandlers`): Override default message handlers for DMs, mentions, and subscribed threads.

**inlineMedia** (`string[] | ((mimeType: string) => boolean)`): Controls which attachment types are sent as file parts to the model. Types that do not match are described as text summaries. Accepts an array of mime type globs or a predicate function. The default matches the formats supported by major vision models. (Default: `['image/png', 'image/jpeg', 'image/webp', 'application/pdf']`)

**inlineLinks** (`InlineLinkEntry[]`): Promote URLs found in message text to file parts so the model can process linked content. Each entry matches a domain. Disabled by default.

**tools** (`boolean`): Include channel-specific tools (\`add\_reaction\`, \`remove\_reaction\`). Set to \`false\` for models that do not support function calling. (Default: `true`)

**state** (`StateAdapter`): State adapter for subscriptions and deduplication. Defaults to \`MastraStateAdapter\` backed by the Mastra instance storage. Channels require storage to be configured. (Default: `MastraStateAdapter (from Mastra storage)`)

**userName** (`string`): Bot display name shown in platform messages. Defaults to the agent's \`name\`, or \`'Mastra'\` if no name is set. (Default: `` agent's `name` ``)

**threadContext** (`{ maxMessages?: number; addSystemMessage?: boolean }`): How the agent picks up context about the current thread. \`maxMessages\` controls how many recent platform messages are fetched on first mention (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. (Default: `{ maxMessages: 10, addSystemMessage: true }`)

**chatOptions** (`Omit<ChatConfig, 'adapters' | 'state' | 'userName'>`): Additional options passed directly to the \[Chat SDK]\(https\://chat-sdk.dev/docs/usage). Use for advanced configuration such as \`dedupeTtlMs\`, \`fallbackStreamingPlaceholderText\`, \`lockScope\`, and \`messageHistory\`.

**resolveResourceId** (`(ctx: ResolveResourceIdContext) => string | Promise<string>`): Decide which \`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.

## Per-adapter options

Wrap an adapter in a `ChannelAdapterConfig` object to set per-adapter options:

```typescript
import { Agent } from '@mastra/core/agent'
import { createDiscordAdapter } from '@chat-adapter/discord'
import { createSlackAdapter } from '@chat-adapter/slack'

const agent = new Agent({
  name: 'Example',
  instructions: '...',
  model: 'openai/gpt-5.5',
  channels: {
    adapters: {
      discord: {
        adapter: createDiscordAdapter(),
        toolDisplay: 'text',
        cors: {
          origin: ['https://customer-saas.example'],
          credentials: true,
        },
        gateway: false,
      },
      slack: createSlackAdapter(), // Plain adapter uses defaults
    },
  },
})
```

**adapter** (`Adapter`): The Chat SDK adapter instance for this platform.

**gateway** (`boolean`): Start a persistent Gateway WebSocket listener for receiving DMs, @mentions, and reactions. Set to \`false\` for serverless deployments that only need webhook-based interactions. (Default: `true`)

**cards** (`boolean`): \*\*Deprecated\*\* — use \`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** (`CorsOptions`): CORS configuration for this adapter webhook route. Use this for browser-based channel adapters that need cross-origin credentials.

**formatError** (`(error: Error) => PostableMessage`): Override how errors are rendered in the chat. Return a user-friendly message instead of exposing the raw error. (Default: `"❌ Error: <error.message>"`)

**formatToolCall** (`(args: { toolName: string; args: unknown; result: unknown; isError: boolean }) => PostableMessage | null`): \*\*Deprecated\*\* — use \`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** (`boolean | { updateIntervalMs?: number }`): Stream agent text deltas to the channel as the agent generates them instead of buffering and posting once per step. Requires the underlying adapter to support post-and-edit streaming. Slack defaults to \`true\`; other adapters default to \`false\`. (Default: `false (true for Slack)`)

**toolDisplay** (`'cards' | 'text' | 'timeline' | 'grouped' | 'hidden' | ToolDisplayFn`): How tool calls are rendered in the channel. \`"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 active streaming widget, or \`undefined\` to skip rendering that event. Approve/deny prompts always render as a separate card regardless of mode. (Default: `'cards' ('grouped' for Slack)`)

**typingStatus** (`boolean | ((chunk: AgentChunkType, ctx: TypingStatusContext) => string | false | null | undefined | void)`): Control the platform typing indicator. \`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. (Default: `true`)

## 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 ship 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`) 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.

Approve/deny prompts (`requireApproval`) always render as a separate card regardless of mode, because inline task entries can't carry interactive buttons.

```typescript
import { Agent } from '@mastra/core/agent'
import { createSlackAdapter } from '@chat-adapter/slack'

const agent = new Agent({
  name: 'Streaming Agent',
  instructions: '...',
  model: 'openai/gpt-5.5',
  channels: {
    adapters: {
      slack: {
        adapter: createSlackAdapter(),
        streaming: true, // already the Slack default
        toolDisplay: 'timeline',
      },
    },
  },
})
```

## 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.

`defaultTypingStatus` is exported from `@mastra/core/channels` so you can fall back to the built-in defaults for chunks you don't handle.

```typescript
import { Agent } from '@mastra/core/agent'
import { defaultTypingStatus } from '@mastra/core/channels'
import { createDiscordAdapter } from '@chat-adapter/discord'

const agent = new Agent({
  name: 'Custom Typing Agent',
  instructions: '...',
  model: 'openai/gpt-5.5',
  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)
        },
      },
    },
  },
})
```

## 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)
- **`false`**: disables the handler entirely
- **A function** `(thread, message, defaultHandler) => Promise<void>`: wraps or replaces the default

```typescript
import { Agent } from '@mastra/core/agent'
import { createSlackAdapter } from '@chat-adapter/slack'

const agent = new Agent({
  name: 'Custom Handler Agent',
  instructions: '...',
  model: 'openai/gpt-5.5',
  channels: {
    adapters: {
      slack: createSlackAdapter(),
    },
    handlers: {
      onMention: async (thread, message, defaultHandler) => {
        console.log('Received mention:', message.text)
        await defaultHandler(thread, message)
      },
      onDirectMessage: false,
    },
  },
})
```

**onDirectMessage** (`ChannelHandler | false`): Called when the bot receives a direct message.

**onMention** (`ChannelHandler | false`): Called when the bot is @mentioned in a channel or thread.

**onSubscribedMessage** (`ChannelHandler | false`): Called for messages in threads the agent has subscribed to.

The `ChannelHandler` function signature:

```typescript
type ChannelHandler = (
  thread: Thread,
  message: Message,
  defaultHandler: (thread: Thread, message: Message) => Promise<void>,
) => Promise<void>
```

## 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.

```typescript
import { Agent } from '@mastra/core/agent'
import { createSlackAdapter } from '@chat-adapter/slack'

const agent = new Agent({
  name: 'SSO Agent',
  instructions: '...',
  model: 'openai/gpt-5.5',
  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** (`string`): Platform name (e.g. \`slack\`, \`discord\`).

**thread** (`Thread`): The channel thread the message arrived on. Use \`thread.isDM\` to tell DMs apart from group/channel threads.

**message** (`Message`): The incoming message. \`message.author.userId\` is the actor/sender, not necessarily the memory owner.

**defaultResourceId** (`string`): The built-in default (\`${platform}:${message.author.userId}\`). Return this to keep the current behavior.

## Inline media

Controls which attachment types (images, video, PDFs, etc.) are sent as file parts to the model. Types that do not 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 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.

```typescript
type InlineLinkEntry =
  | string // Domain pattern (HEAD determines mime type)
  | { match: string; mimeType: string } // Domain + forced mime type (skips HEAD)
```

## Related

- [Channels guide](https://mastra.ai/docs/agents/channels): Concepts, quickstart, and platform setup
- [Agent class](https://mastra.ai/reference/agents/agent): Constructor parameters and methods
- [Chat SDK adapters](https://chat-sdk.dev/adapters): Adapter configuration and platform setup