Skip to main content

message-extensions

Use this skill when the user is building Microsoft Teams message extensions on Teams SDK 2.1 — search commands (`message.ext.query`), action commands (`message.ext.submit`, `message.ext.open`), link unfurling (`message.ext.query-link`), item selection, settings pages. Triggers on "Teams message extension", "compose extension", "link unfurling", "message.ext.query", "search command Teams", "action command".

Source facts

Repository
Agents-Store/opencode-plugins
Last source activity
October 3, 2026 at 23:16
Detected SKILL.md language
English
Stars
0
Forks
0

Install options

The review-first prompt is selected by default. You can switch to a direct command or download a local copy.

Review the source files

Read SKILL.md and any companion files shown by SkillsMP before deciding whether to install.

Showing SKILL.md

SKILL.md
Source instructions · Read-only preview
name
message-extensions
description
Use this skill when the user is building Microsoft Teams message extensions on Teams SDK 2.1 — search commands (`message.ext.query`), action commands (`message.ext.submit`, `message.ext.open`), link unfurling (`message.ext.query-link`), item selection, settings pages. Triggers on "Teams message extension", "compose extension", "link unfurling", "message.ext.query", "search command Teams", "action command".
# Message extensions A message extension lets users call the bot from the compose box or from a message's menu. Each kind is a route: | Kind | Route | Purpose | |---|---|---| | Search command | `message.ext.query` | The user typed a search in the compose-box menu | | Item selection | `message.ext.select-item` | The user tapped a result that carried a `tap` action | | Action command | `message.ext.submit` | The user submitted an action command's form | | Action command with a dynamic form | `message.ext.open` | The command has `fetchTask: true`; the bot returns the dialog | | Link unfurling | `message.ext.query-link` | The user pasted a URL on a registered domain | | Settings | `message.ext.query-settings-url`, `message.ext.setting` | Settings page link and its result | Commands are declared in the app manifest. The scaffolded project keeps no manifest file; edit it through the CLI (`teams app manifest download <teamsAppId> manifest.json`, edit, `teams app manifest upload manifest.json <teamsAppId>`) or one field at a time with `teams app manifest update`. See `cli-recipes`. ## 1. Search command Manifest fragment (the `botId` of a downloaded manifest is already the real bot id; in a manifest template it is `${{BOT_ID}}`): ```jsonc { "composeExtensions": [{ "botId": "${{BOT_ID}}", "commands": [{ "id": "searchQuery", "type": "query", "title": "Search issues", "context": ["compose", "commandBox"], "parameters": [{ "name": "q", "title": "Query", "inputType": "text" }] }] }] } ``` Handler. Each result is an Adaptive Card (shown when picked) plus a `preview` card (shown in the result list): ```ts import { ThumbnailCard, cardAttachment } from '@microsoft/teams.api'; import { AdaptiveCard, FactSet, Fact, TextBlock } from '@microsoft/teams.cards'; app.on('message.ext.query', async ({ activity }) => { const { commandId } = activity.value; const q = String(activity.value.parameters?.[0]?.value ?? ''); if (commandId !== 'searchQuery') return { status: 400 }; const issues = await searchIssues(q); const attachments = issues.map((issue) => { const card = new AdaptiveCard( new TextBlock(`${issue.id}: ${issue.title}`, { weight: 'Bolder', wrap: true }), new FactSet(new Fact('Status', issue.status), new Fact('Assignee', issue.assignee)), ); const preview = { title: `${issue.id}: ${issue.title}`, text: issue.status } satisfies ThumbnailCard; return { ...cardAttachment('adaptive', card), preview: cardAttachment('thumbnail', preview) }; }); return { composeExtension: { type: 'result', attachmentLayout: 'list', attachments }, } as const; }); ``` Response types: `result` (normal), `auth` (ask the user to sign in), `config` (send to a settings page). Without a `tap` on the preview, tapping a result inserts its card into the compose box. With a `tap` of type `invoke`, Teams calls `message.ext.select-item` instead: ```ts app.on('message.ext.select-item', async ({ activity, send }) => { const { option } = activity.value; // the value you put in the tap action await send(`Selected item: ${String(option)}`); return { status: 200 }; }); ``` ## 2. Action commands ```jsonc { "composeExtensions": [{ "commands": [ { "id": "createCard", "type": "action", "title": "Create card", "context": ["compose", "commandBox"], "fetchTask": false, "parameters": [{ "name": "title", "title": "Title", "inputType": "text" }] }, { "id": "fetchConversationMembers", "type": "action", "title": "List members", "context": ["compose"], "fetchTask": true } ] }] } ``` - `fetchTask: false` with parameters: Teams renders the form from the manifest and posts it to `message.ext.submit`. - `fetchTask: true`: Teams first asks the bot for the form (`message.ext.open`), then posts the result to `message.ext.submit`. ```ts import { cardAttachment } from '@microsoft/teams.api'; import { AdaptiveCard, TextBlock } from '@microsoft/teams.cards'; app.on('message.ext.submit', async ({ activity }) => { const { commandId } = activity.value; if (commandId !== 'createCard') throw new Error(`Unknown commandId: ${commandId}`); const created = await createTask(activity.value.data); // form values keyed by parameter name return { composeExtension: { type: 'result', attachmentLayout: 'list', attachments: [cardAttachment('adaptive', new AdaptiveCard(new TextBlock(created.title, { weight: 'Bolder' })))], }, } as const; }); app.on('message.ext.open', async ({ activity, api }) => { const members = await api.conversations.getMembers(activity.conversation.id); const card = new AdaptiveCard(new TextBlock(members.map((m) => m.name).join(', '), { wrap: true })); return { task: { type: 'continue', value: { title: 'Conversation members', height: 'small', width: 'small', card: cardAttachment('adaptive', card) }, }, } as const; }); ``` A command with `context: ["message"]` receives the selected message as `activity.value.messagePayload` (`body.content`, `attachments`, `createdDateTime`, `linkToMessage`). ## 3. Link unfurling ```jsonc { "composeExtensions": [{ "messageHandlers": [{ "type": "link", "value": { "domains": ["links.example.com"] } }] }] } ``` ```ts import { ThumbnailCard, cardAttachment } from '@microsoft/teams.api'; import { AdaptiveCard, TextBlock } from '@microsoft/teams.cards'; app.on('message.ext.query-link', async ({ activity }) => { const { url } = activity.value; if (!url) return { status: 400 }; const meta = await fetchPreview(url); const card = new AdaptiveCard(new TextBlock(meta.title, { weight: 'Bolder', size: 'Large' }), new TextBlock(url, { size: 'Small' })); const thumbnail = { title: meta.title, text: url } satisfies ThumbnailCard; return { composeExtension: { type: 'result', attachmentLayout: 'list', attachments: [{ ...cardAttachment('adaptive', card), preview: cardAttachment('thumbnail', thumbnail) }], }, } as const; }); ``` The domain must be in `messageHandlers[].value.domains`, and the host of the link page in `validDomains`. ## 4. Settings The settings page is a web page served by the bot (`app.tab('settings', dir)`) that calls `microsoftTeams.authentication.notifySuccess(value)` when saved. ```ts app.on('message.ext.query-settings-url', async () => ({ composeExtension: { type: 'config', suggestedActions: { actions: [{ type: 'openUrl', title: 'Settings', value: `${process.env['BOT_ENDPOINT']}/tabs/settings` }], }, }, } as const)); app.on('message.ext.setting', async ({ activity, send }) => { const { state } = activity.value; if (state === 'CancelledByUser') return { status: 400 }; await send(`Selected option: ${String(state)}`); return { status: 200 }; }); ``` Store the setting in your own storage keyed by `activity.from.id` (the app-level `storage` is deprecated). ## Common pitfalls - **The command never fires** — it is missing from `composeExtensions[].commands`, or the extension's `botId` is not the bot's client id. - **The action command's form never opens** — `fetchTask: true` needs `message.ext.open`; `dialog.open.*` handlers do not receive it. - **Link unfurling does nothing** — the domain is missing from `messageHandlers[].value.domains` or `validDomains`. - **A 400 or empty result** — the handler returned something other than `{ composeExtension: … }`, `{ task: … }` or `{ status }`. - **`attachmentLayout`** — `list` is the default; use `grid` for thumbnails.
View on GitHub