ui.message-extensions-ts
purpose
Search-based and action-based message extensions (compose extensions) for Teams bots using the Teams AI Library v2.
rules
- Configure message extensions in
appPackage/manifest.jsonunder thecomposeExtensionsarray. Each extension has abotId,commandsarray, and each command specifiestype(queryfor search,actionfor task module). learn.microsoft.com -- Message extensions - Handle search queries with
app.on('message.ext.query', handler). The handler receives the query text inactivity.value.parameters[0].valueand must return a response withcomposeExtension.type: 'result'containing an attachments array. learn.microsoft.com -- Search extensions - Set
attachmentLayoutto'list'for vertical result layout or'grid'for a tile grid. Use'list'for text-heavy results and'grid'for image-heavy results. learn.microsoft.com -- Respond to search - Each attachment in the results array needs both a
content(the full Adaptive Card inserted into the compose box) and apreview(a smaller Thumbnail Card shown in the search results list). The preview usescontentType: 'application/vnd.microsoft.card.thumbnail'. learn.microsoft.com -- Respond to search - Handle action-based extensions with two routes:
app.on('message.ext.open', handler)for displaying the task module form (composeExtension/fetchTask) andapp.on('message.ext.submit', handler)for processing the submitted data (composeExtension/submitAction). learn.microsoft.com -- Action extensions - The
message.ext.openhandler returns a task module response identical todialog.open:{ status: 200, body: { task: { type: 'continue', value: { title, card } } } }. learn.microsoft.com -- Action extensions - The
message.ext.submithandler receives form data inactivity.value.dataand can return a card to insert into the compose box or perform a server-side action. learn.microsoft.com -- Action extensions - Manifest command parameters define the search fields displayed in the Teams UI. Each parameter has
name,title, and optionallydescriptionandinputType. The first parameter is the default search field. learn.microsoft.com -- Define search command - Search result counts should be limited (10-15 items) because Teams truncates long result lists. Always handle empty query strings gracefully by returning popular or recent results. learn.microsoft.com -- Search extensions
- Handle link unfurling with
app.on('message.ext.query-link', handler). This fires when a user pastes a URL matching a domain in the manifest'smessageHandlers. Return a card attachment to preview the link. learn.microsoft.com -- Link unfurling
patterns
Search-based message extension
import { App } from '@microsoft/teams.apps';
import { ConsoleLogger } from '@microsoft/teams.common';
import { DevtoolsPlugin } from '@microsoft/teams.dev';
// Manifest excerpt (appPackage/manifest.json):
// {
// "composeExtensions": [{
// "botId": "${{BOT_ID}}",
// "commands": [{
// "id": "searchCmd",
// "type": "query",
// "title": "Search Products",
// "parameters": [{ "name": "query", "title": "Search query" }]
// }]
// }]
// }
interface Product {
id: string;
title: string;
description: string;
price: number;
imageUrl: string;
}
async function searchProducts(query: string): Promise<Product[]> {
// Replace with your actual search logic
const products: Product[] = [
{ id: '1', title: 'Widget Pro', description: 'A premium widget', price: 29.99, imageUrl: 'https://example.com/widget.png' },
{ id: '2', title: 'Gadget Plus', description: 'An advanced gadget', price: 49.99, imageUrl: 'https://example.com/gadget.png' },
];
return products.filter(p =>
p.title.toLowerCase().includes(query.toLowerCase())
);
}
const app = new App({
logger: new ConsoleLogger('ext-bot'),
plugins: [new DevtoolsPlugin()],
});
app.on('message.ext.query', async ({ activity }) => {
const query = activity.value.parameters?.[0]?.value || '';
const results = await searchProducts(query);
return {
status: 200,
body: {
composeExtension: {
type: 'result',
attachmentLayout: 'list',
attachments: results.map(item => ({
// Full card inserted into compose box when selected
contentType: 'application/vnd.microsoft.card.adaptive',
content: {
type: 'AdaptiveCard',
version: '1.5',
body: [
{ type: 'TextBlock', text: item.title, weight: 'Bolder', size: 'Large' },
{ type: 'TextBlock', text: item.description, wrap: true },
{ type: 'TextBlock', text: `Price: $${item.price}`, weight: 'Bolder' },
],
},
// Preview card shown in search results list
preview: {
contentType: 'application/vnd.microsoft.card.thumbnail',
content: {
title: item.title,
text: `$${item.price} - ${item.description}`,
images: [{ url: item.imageUrl }],
},
},
})),
},
},
};
});
app.start(3978);Action-based message extension with task module
import { App } from '@microsoft/teams.apps';
import { ConsoleLogger } from '@microsoft/teams.common';
import { DevtoolsPlugin } from '@microsoft/teams.dev';
// Manifest excerpt (appPackage/manifest.json):
// {
// "composeExtensions": [{
// "botId": "${{BOT_ID}}",
// "commands": [{
// "id": "createItem",
// "type": "action",
// "title": "Create Item",
// "fetchTask": true
// }]
// }]
// }
const app = new App({
logger: new ConsoleLogger('action-ext-bot'),
plugins: [new DevtoolsPlugin()],
});
// Open a task module form when the action is triggered
app.on('message.ext.open', async () => {
return {
status: 200,
body: {
task: {
type: 'continue',
value: {
title: 'Create New Item',
width: 'medium',
height: 'medium',
card: {
contentType: 'application/vnd.microsoft.card.adaptive',
content: {
type: 'AdaptiveCard',
version: '1.5',
body: [
{
type: 'TextBlock',
text: 'Create a new item',
weight: 'Bolder',
size: 'Large',
},
{
type: 'Input.Text',
id: 'title',
label: 'Title',
isRequired: true,
errorMessage: 'Title is required',
},
{
type: 'Input.Text',
id: 'description',
label: 'Description',
isMultiline: true,
},
{
type: 'Input.ChoiceSet',
id: 'priority',
label: 'Priority',
value: 'medium',
choices: [
{ title: 'Low', value: 'low' },
{ title: 'Medium', value: 'medium' },
{ title: 'High', value: 'high' },
],
},
],
actions: [
{ type: 'Action.Submit', title: 'Create' },
],
},
},
},
},
},
};
});
// Process the submitted form data
app.on('message.ext.submit', async ({ activity, send }) => {
const data = activity.value.data;
const { title, description, priority } = data;
// Create the item in your backend
const itemId = `ITEM-${Date.now()}`;
await send(`Created item "${title}" (${priority} priority) - ID: ${itemId}`);
});
app.start(3978);Combined search and action extensions
import { App } from '@microsoft/teams.apps';
// Manifest excerpt (appPackage/manifest.json):
// {
// "composeExtensions": [{
// "botId": "${{BOT_ID}}",
// "commands": [
// {
// "id": "searchItems",
// "type": "query",
// "title": "Search Items",
// "parameters": [{ "name": "query", "title": "Search" }]
// },
// {
// "id": "createItem",
// "type": "action",
// "title": "Create Item",
// "fetchTask": true
// }
// ]
// }]
// }
const app = new App();
// Search extension handler
app.on('message.ext.query', async ({ activity }) => {
const query = activity.value.parameters?.[0]?.value || '';
const commandId = activity.value.commandId;
// You can route by commandId if multiple search commands exist
const items = [
{ id: '1', title: 'Task Alpha', status: 'open' },
{ id: '2', title: 'Task Beta', status: 'closed' },
].filter(i => i.title.toLowerCase().includes(query.toLowerCase()));
return {
status: 200,
body: {
composeExtension: {
type: 'result',
attachmentLayout: 'list',
attachments: items.map(item => ({
contentType: 'application/vnd.microsoft.card.adaptive',
content: {
type: 'AdaptiveCard',
version: '1.5',
body: [
{ type: 'TextBlock', text: item.title, weight: 'Bolder' },
{ type: 'TextBlock', text: `Status: ${item.status}` },
],
},
preview: {
contentType: 'application/vnd.microsoft.card.thumbnail',
content: {
title: item.title,
text: `Status: ${item.status}`,
},
},
})),
},
},
};
});
// Action extension: open form
app.on('message.ext.open', async () => {
return {
status: 200,
body: {
task: {
type: 'continue',
value: {
title: 'Create Item',
card: {
contentType: 'application/vnd.microsoft.card.adaptive',
content: {
type: 'AdaptiveCard',
version: '1.5',
body: [
{ type: 'Input.Text', id: 'title', label: 'Title', isRequired: true },
{ type: 'Input.Text', id: 'notes', label: 'Notes', isMultiline: true },
],
actions: [
{ type: 'Action.Submit', title: 'Create' },
],
},
},
},
},
},
};
});
// Action extension: process submission
app.on('message.ext.submit', async ({ activity, send }) => {
const { title, notes } = activity.value.data;
await send(`Created: ${title}${notes ? ` - ${notes}` : ''}`);
});
app.start(3978);pitfalls
- Missing manifest
composeExtensions: Message extensions require thecomposeExtensionsarray in the manifest. Without it, the extension does not appear in the Teams compose box. Update the manifest and re-sideload after changes. - Empty query handling: Users often open the search extension without typing. Handle empty or blank
querystrings by returning popular/recent results instead of an empty list. - Missing preview card: Each search result attachment must include a
previewwithcontentType: 'application/vnd.microsoft.card.thumbnail'. Without it, the result appears blank in the search results list. - Wrong route handler: Search uses
message.ext.query(notmessage.ext.open). Action usesmessage.ext.open+message.ext.submit. Mixing them up results in handlers never firing. - Too many results: Teams limits the number of displayed results. Return 10-15 items maximum. Longer lists are silently truncated.
- Attachment layout mismatch: Using
'grid'layout requires images in the preview. Using'grid'with text-only thumbnails produces a poor visual experience. Match layout to content type. - Not setting
fetchTask: truefor action commands: In the manifest, action commands must have"fetchTask": truefor themessage.ext.openhandler to fire. Without it, Teams does not invoke the task module. - Preview vs content confusion: The
previewis what users see in the results dropdown. Thecontentis what gets inserted when they select it. A missing or incorrectcontentcard means the wrong card (or nothing) is inserted.
references
- Message extensions overview
- Define search commands
- Respond to search commands
- Define action commands
- Respond to action commands
- Link unfurling
- Teams AI Library v2 -- GitHub
instructions
This expert covers search-based and action-based message extensions (compose extensions) in Microsoft Teams bots built with the Teams AI Library v2 (@microsoft/teams.ts) in TypeScript. Use it when you need to:
- Configure
composeExtensionsin the manifest with search (query) or action commands - Handle search queries with
app.on('message.ext.query', ...)and return result attachments with previews - Handle action extensions with
app.on('message.ext.open', ...)for task modules andapp.on('message.ext.submit', ...)for processing - Build thumbnail preview cards for search results
- Choose between
'list'and'grid'attachment layouts - Combine search and action commands in a single extension
Pair with ui.adaptive-cards-ts.md for card construction and ui.dialogs-task-modules-ts.md for task module patterns used in action extensions. Pair with runtime.manifest-ts.md for composeExtensions manifest configuration, and ui.adaptive-cards-ts.md for building card attachments returned by extensions.
research
Deep Research prompt:
"Write a micro expert on Message Extensions in Teams (TypeScript). Cover manifest composeExtensions configuration, search-based query flow (message.ext.query handler, parameters, result attachments with preview), action-based flow (message.ext.open for task module, message.ext.submit for processing), attachment layouts (list/grid), thumbnail preview cards, link unfurling, and common pitfalls. Include 2-3 canonical TypeScript code examples."