diff --git a/plugins/microsoft-365-agents-toolkit/README.md b/plugins/microsoft-365-agents-toolkit/README.md new file mode 100644 index 0000000..3292999 --- /dev/null +++ b/plugins/microsoft-365-agents-toolkit/README.md @@ -0,0 +1,55 @@ +# M365 Agents Toolkit + +Toolkit for building Microsoft 365 Copilot declarative agents. + +## Installation + +### Via GitHub Copilot CLI Plugin Marketplace + +```bash +/plugin install microsoft-365-agents-toolkit@work-iq +``` + +## Usage + +``` +# Develop an agent +"Scaffold a new declarative agent for HR FAQ" + +# Configure capabilities +"Add web search to my agent" + +# Deploy +"Deploy my agent with ATK" + +# Create evals +"Create an eval suite for my agent based on it's capabilities." + +# Run evals +"Run my evals for the agent" + +# Analyze and improve +"Analyze the evaluation failures by root cause, and recommend targeted agent instruction changes" + +# Regression check after agent changes +"I changed my agent instructions. Re-run the evals with stable concurrency and compare the new results to .evals\baseline.json" +``` + +The evaluator skill uses the public preview M365 Copilot eval CLI through package-scoped `npx`. Learn more about the preview, docs, issues, and feedback channels in the public [m365-copilot-eval repository](https://github.com/microsoft/m365-copilot-eval). + +```powershell +npx -y --package @microsoft/m365-copilot-eval@latest runevals --prompts-file evals\evals.json --output .evals\latest.json +``` + +## Skills + +| Skill | What It Does | +|-------|-------------| +| [**install-atk**](./skills/install-atk/SKILL.md) | Install or update the ATK CLI and VS Code extension | +| [**declarative-agent-developer**](./skills/declarative-agent-developer/SKILL.md) | Scaffolding, JSON manifest authoring, capability configuration, security patterns, deployment via ATK CLI | +| [**ui-widget-developer**](./skills/ui-widget-developer/SKILL.md) | Build MCP servers with OpenAI Apps SDK widget rendering for Copilot Chat | +| [**m365-agent-evaluator**](./skills/m365-agent-evaluator/SKILL.md) | Generate, run, and analyze evaluation suites for M365 Copilot declarative agents | + +## License + +See the root [LICENSE](../../LICENSE) file. diff --git a/plugins/microsoft-365-agents-toolkit/skills/declarative-agent-developer/SKILL.md b/plugins/microsoft-365-agents-toolkit/skills/declarative-agent-developer/SKILL.md new file mode 100644 index 0000000..a974c33 --- /dev/null +++ b/plugins/microsoft-365-agents-toolkit/skills/declarative-agent-developer/SKILL.md @@ -0,0 +1,193 @@ +--- +name: declarative-agent-developer +description: > + Create, build, deploy, and localize declarative agents for M365 Copilot and Teams. + USE THIS SKILL for ANY task involving a declarative agent — including localization, + scaffolding, editing manifests, adding capabilities, and deploying. + Localization requires tokenized manifests and language files that only this skill knows how to produce. + Triggers: "create agent", "create a declarative agent", "new declarative agent", + "scaffold an agent", "new agent project", "add a capability", "add a plugin", + "configure my agent", "deploy my agent", "fix my agent manifest", "edit my agent", + "localize my agent", "add localization", "translate my agent", "multi-language agent", + "add an API plugin", "add an MCP plugin", "add OAuth to my plugin", + "review instructions", "improve instructions", "fix my instructions" +--- + +# M365 Agent Developer + +## Telemetry Tagging + +Before running any `atk` CLI commands, set the session environment variable so all CLI invocations are tagged as skill-initiated: +```bash +export ATK_CLI_SKILL=true +``` +Run this once at the start of the session. All subsequent `atk` commands in the same terminal will inherit it. + +## ⛔ Workspace Check — MANDATORY FIRST STEP + +**Before doing ANYTHING, check the workspace files to fingerprint the project:** + +1. Run `npx -y --package @microsoft/m365agentstoolkit-cli atk --version` to confirm ATK CLI is installed. If not found → **Stop.** Tell the user to install ATK. +2. Check for `m365agents.yml` or `teamsApp.yml` at the project root. +3. Check for `appPackage/declarativeAgent.json`. +4. Check for non-agent indicators (`package.json` with express/react/next, `src/index.js`, `app.py`, etc.) + +**Then follow the decision gate:** + +| Condition | Gate | Action | +|-----------|------|--------| +| Non-agent project files, no `appPackage/` | **Reject** | Text-only response. No files, no commands. | +| No manifest, user wants to edit/deploy | **Reject** | Text-only response. Explain manifest is missing. | +| No manifest, user wants new project | **Scaffold** | → [Scaffolding Workflow](references/scaffolding-workflow.md) | +| Manifest exists with errors | **Fix** | Detect → Inform → Ask (see below). Do NOT deploy. | +| Valid project, user reports behavior issues | **Review** | → [Instruction Review](references/instruction-review.md) — run the full 5-phase review workflow | +| Valid agent project | **Edit** | → [Editing Workflow](references/editing-workflow.md) | + +> **Detailed gate rules, examples, and anti-patterns:** [Workspace Gates](references/workspace-gates.md) + +### 🚫 HARD REJECTION RULES — No Exceptions + +**These rules override ALL other instructions.** If any of these apply, you MUST stop immediately. + +1. **NEVER create `declarativeAgent.json` yourself.** If the manifest is missing and the user asked to edit/modify/deploy, respond with text only: explain the manifest is missing, suggest `npx -y --package @microsoft/m365agentstoolkit-cli atk new` or starting from scratch. Do NOT create the file, do NOT create `appPackage/`, do NOT "help" by scaffolding implicitly. + +2. **NEVER create files in a non-agent project.** If the workspace is an Express/React/Django/etc. app without `appPackage/`, your response must be text-only. Do NOT create any files, do NOT run any commands. + +3. **NEVER deploy when errors exist.** If the agent manifest has errors, STOP. Do NOT run `npx -y --package @microsoft/m365agentstoolkit-cli atk provision` — not "to test", not "to demonstrate the error", not "to see what happens". Report the errors and ask the user how to proceed. + +### 🔍 Detect → Inform → Ask (Error-Handling Protocol) + +When you encounter ANY problem (missing files, malformed JSON, validation errors, incompatible features), you MUST follow this sequence **in order**: + +1. **Detect** — Identify the specific problem. For JSON issues, attempt to parse the file and report syntax errors. For missing fields, check the manifest against the [Schema](references/schema.md). +2. **Inform** — Tell the user BEFORE taking any action. Describe exactly what is wrong ("declarativeAgent.json has malformed JSON: missing comma on line 12, unclosed array on line 18"). +3. **Ask** — Wait for the user's response before making changes. Do NOT silently fix, auto-correct, or work around the problem. + +**This protocol applies to:** +- Missing `declarativeAgent.json` → Detect (file not found) → Inform ("no manifest found") → Ask ("would you like to create a new agent?") +- Malformed JSON → Detect (parse errors) → Inform (list specific syntax issues) → Ask ("should I fix these syntax errors?") +- Validation errors → Detect (parse and check manifest) → Inform (list all errors) → Ask ("how would you like to fix these?") +- Version incompatibility → Detect (feature requires newer version) → Inform ("this feature requires v1.6, your agent is v1.4") → Ask ("should I upgrade?") + +--- + +## Phase Routing + +| Scenario | Workflow Reference | +|----------|-------------------| +| Creating a NEW project from scratch | [Scaffolding Workflow](references/scaffolding-workflow.md) | +| Working with existing `.json` manifests | [Editing Workflow](references/editing-workflow.md) | +| Adding an API plugin | [API Plugins](references/api-plugins.md) | +| Adding an MCP server | [MCP Plugin](references/mcp-plugin.md) | +| Adding OAuth to an MCP or API plugin | [Authentication](references/authentication.md) | +| Reviewing or improving existing agent instructions | [Instruction Review](references/instruction-review.md) | +| User reports agent gives generic/wrong answers | [Instruction Review](references/instruction-review.md) | +| Localizing an agent into multiple languages | [Localization](references/localization.md) | +| Adding a new language to an already-localized agent | [Localization](references/localization.md) | +| Writing agent instructions | [Conversation Design](references/conversation-design.md) | + +--- + +## ATK CLI Setup + +Before running any ATK commands, check if the ATK CLI is available by running `npx -y --package @microsoft/m365agentstoolkit-cli atk --version`. If not found, **STOP and tell the user** — do NOT attempt to install it yourself. + +All commands use the `npx -y --package @microsoft/m365agentstoolkit-cli atk` prefix (e.g., `npx -y --package @microsoft/m365agentstoolkit-cli atk provision --env local`). + +--- + +## Critical Rules + +### 1. Deploy After EVERY Edit + +After ANY change to files in `appPackage/`, you MUST deploy and show the test link before responding: + +```bash +npx -y --package @microsoft/m365agentstoolkit-cli atk provision --env local --interactive false +``` + +Then read `M365_TITLE_ID` from `env/.env.local` and **ALWAYS** present the review UX: + +``` +✅ Agent deployed successfully! + +🚀 Test Your Agent in M365 Copilot: +🔗 https://m365.cloud.microsoft/chat/?titleId={M365_TITLE_ID} +``` + +**⛔ Never respond without this link.** If you deployed, the test link MUST appear in your response. This is not optional — it is how the user tests their agent. + +- If the manifest has errors → **STOP. Fix errors. Do NOT deploy.** +- Exception: user explicitly asks you not to deploy + +### 2. Never Invent Content or Create Missing Files + +- Do NOT invent placeholder names, descriptions, or instructions +- Do NOT create `declarativeAgent.json` or `appPackage/` if they don't exist — this is a REJECT scenario, not a "help by creating" scenario +- If required fields are missing, report the gaps, and ASK the user +- If JSON is malformed, follow Detect → Inform → Ask: parse the file first, tell the user what's broken, then ask before fixing. Use surgical edits (not rewrites) +- **⛔ NEVER set placeholder values for environment variables** that are populated by automation (e.g., `_MCP_AUTH_ID`, `TEAMS_APP_ID`). Leave them empty (`VAR_NAME=`). Placeholders will be treated as real values and will NOT be overwritten by provisioning. + +### 3. Schema Version Compatibility + +Before adding ANY feature, read the `version` field in `declarativeAgent.json` and check the [Schema](references/schema.md) feature matrix. If the feature isn't supported in that version, **refuse** and offer to upgrade. + +Key version gates: +- `sensitivity_label`, `worker_agents`, `EmbeddedKnowledge` → **v1.6 only** +- `Meetings` → **v1.5+** +- `ScenarioModels`, `behavior_overrides`, `disclaimer` → **v1.4+** +- `Dataverse`, `TeamsMessages`, `Email`, `People` → **v1.3+** + +### 4. Use `npx -y --package @microsoft/m365agentstoolkit-cli atk add action` for API Plugins — NEVER Create Plugin Files Manually + +You are **forbidden** from manually creating `ai-plugin.json`, OpenAPI specs, adaptive cards, or editing the `actions` array. Use the CLI: + +```bash +# ⛔ Always list ALL operations in a single call — NEVER run separate calls per operation +npx -y --package @microsoft/m365agentstoolkit-cli atk add action --api-plugin-type api-spec --openapi-spec-location URL --api-operation "GET /path,POST /path,PATCH /path/{id},DELETE /path/{id}" -i false +``` + +Run a **single** `npx -y --package @microsoft/m365agentstoolkit-cli atk add action` call per OpenAPI spec, listing **all** operations as a comma-separated list in `--api-operation`. Never run separate `npx -y --package @microsoft/m365agentstoolkit-cli atk add action` calls for different operations from the same spec — this creates multiple plugins instead of one. If `npx -y --package @microsoft/m365agentstoolkit-cli atk add action` fails, report the error; do NOT fall back to manual creation. + +> **Exception:** MCP servers are not supported by `npx -y --package @microsoft/m365agentstoolkit-cli atk add action`. Use the [MCP Plugin workflow](references/mcp-plugin.md) instead. + +### 5. MCP Server Integration + +When the user mentions an MCP server URL, follow the [MCP Plugin workflow](references/mcp-plugin.md). You MUST discover tools via the MCP protocol handshake (initialize → notifications/initialized → tools/list) — **NEVER fabricate tool names/descriptions**. For authenticated MCP servers, follow the [authentication guide](references/authentication.md) to configure OAuth. + +### 6. Always Update Instructions & Starters After Changes + +Adding a capability or plugin without updating instructions is incomplete. After ANY change: +1. Update instructions to describe the new/changed functionality — every data source should have clear intent coverage (WHEN and WHY to use it) per the [Instruction Review](references/instruction-review.md) quality bar. Built-in capabilities don't need exact names; actions/plugins should be named. +2. **Do NOT list tool names, descriptions, or parameters in instructions** — these are already in the plugin metadata (`ai-plugin.json`, MCP manifests, capability config). Instructions should contain decision logic only: WHEN to use each tool, chaining rules, and failure handling. +3. **Stay within the 8,000-character instruction limit** — if close to the limit, cut tool descriptions first +4. Add at least 1 conversation starter per added capability/plugin +5. Remove starters that reference removed capabilities +6. Run the [Diagnostic Checklist](references/instruction-review.md) against the updated instructions to verify quality + +### 7. App Name Requirement + +Always update the app name and description to something meaningful. Never leave defaults like "My Agent". + +--- + +## References + +### Shared +- **[Authentication](references/authentication.md)** — OAuth discovery, credentials, oauth/register lifecycle, OAuthPluginVault +- **[Best Practices](references/best-practices.md)** — Security, performance, testing, compliance +- **[Conversation Design](references/conversation-design.md)** — Authoring instructions and conversation starters from scratch +- **[Instruction Review](references/instruction-review.md)** — Auditing, diagnosing, and improving existing instructions; anti-pattern detection; before/after rewrites +- **[Deployment](references/deployment.md)** — ATK CLI workflows, environments, CI/CD +- **[Localization](references/localization.md)** — Multi-language support, tokenized manifests, language files +- **[Workspace Gates](references/workspace-gates.md)** — Detailed gate rules, examples, anti-patterns + +### Scaffolding +- **[Scaffolding Workflow](references/scaffolding-workflow.md)** — Step-by-step scaffolding instructions, naming rules, error handling + +### JSON Development +- **[Editing Workflow](references/editing-workflow.md)** — Step-by-step JSON development instructions +- **[Schema](references/schema.md)** — Official JSON schema for agent manifests +- **[API Plugins](references/api-plugins.md)** — OpenAPI integration for JSON agents +- **[MCP Plugin](references/mcp-plugin.md)** — MCP server integration with RemoteMCPServer, OAuth, response semantics, logo handling +- **[Examples](references/examples.md)** — JSON manifest examples diff --git a/plugins/microsoft-365-agents-toolkit/skills/declarative-agent-developer/references/api-plugins.md b/plugins/microsoft-365-agents-toolkit/skills/declarative-agent-developer/references/api-plugins.md new file mode 100644 index 0000000..6f25dc3 --- /dev/null +++ b/plugins/microsoft-365-agents-toolkit/skills/declarative-agent-developer/references/api-plugins.md @@ -0,0 +1,1202 @@ +# API Plugin Architecture for M365 JSON Agents + +> **Note:** This guide is for JSON-based agents. API plugins are defined using JSON manifest files that reference OpenAPI specifications. + +## Overview + +API plugins (also called "actions") allow your M365 Copilot agent to interact with external REST APIs. They consist of: + +1. **OpenAPI Specification** - Describes the REST API endpoints, parameters, and responses +2. **API Plugin Manifest** (JSON) - Configures how M365 Copilot interacts with the API +3. **Declarative Agent Reference** - Links the plugin to your agent via the `actions` array + +--- + +## ⛔ Post-`npx -y --package @microsoft/m365agentstoolkit-cli atk add action` Checklist — MANDATORY + +After running `npx -y --package @microsoft/m365agentstoolkit-cli atk add action`, you **MUST** complete ALL of these before validating and deploying: + +1. **Update `name_for_human`** in ai-plugin.json — descriptive, user-facing name (max 20 chars) +2. **Update `description_for_model`** in ai-plugin.json — detailed guidance for the AI on when and how to use each function +3. **Customize adaptive cards** in `appPackage/adaptiveCards/` for each operation — different layouts per HTTP verb (list view for GET collections, detail view for GET by ID, confirmation for DELETE, etc.) +4. **Add `confirmation` dialogs** for all destructive operations (POST, PUT, PATCH, DELETE) +5. **Deploy** with `npx -y --package @microsoft/m365agentstoolkit-cli atk provision --env local --interactive false` + +Skipping ANY of these steps = incomplete work. The `npx -y --package @microsoft/m365agentstoolkit-cli atk add action` command generates scaffolding — **you must finish the job** by customizing every generated file. + +--- + +## Adding API Plugins with ATK CLI + +> **⚠️ IMPORTANT:** When adding an API, OpenAPI spec, or REST API to your agent, you **MUST** use the `npx -y --package @microsoft/m365agentstoolkit-cli atk add action` command. This is the required method for adding API plugins to M365 Copilot agents. **Do NOT manually create plugin files** - the path resolution between local packaging and M365 service validation is complex and error-prone. + +> **⛔ ONE PLUGIN PER API — HARD RULE:** Always add ALL operations from the same OpenAPI spec in a **single** `npx -y --package @microsoft/m365agentstoolkit-cli atk add action` call. List every operation in the `--api-operation` parameter as a comma-separated list. **NEVER** run separate `npx -y --package @microsoft/m365agentstoolkit-cli atk add action` calls for different operations from the same spec — this creates multiple plugins instead of one unified plugin. One OpenAPI spec = one `npx -y --package @microsoft/m365agentstoolkit-cli atk add action` call = one plugin. + +### The `npx -y --package @microsoft/m365agentstoolkit-cli atk add action` Command + +**ALWAYS use this command** when asked to add an API, OpenAPI specification, or REST API to an M365 Copilot agent: + +```bash +npx -y --package @microsoft/m365agentstoolkit-cli atk add action \ + --api-plugin-type api-spec \ + --openapi-spec-type enter-url-or-open-local-file \ + --openapi-spec-location URL_OR_FILE_PATH \ + --api-operation "OPERATIONS_TO_MAP" \ + -i false +``` + +### Command Parameters + +| Parameter | Description | +|-----------|-------------| +| `--api-plugin-type` | Type of plugin. Use `api-spec` for OpenAPI-based plugins. | +| `--openapi-spec-type` | How to provide the spec. Use `enter-url-or-open-local-file`. | +| `--openapi-spec-location` | URL or local file path to the OpenAPI specification. | +| `--api-operation` | Comma-separated list of operations to include (format: `METHOD /path`). | +| `-i false` | Non-interactive mode. | + +### ⚠️ CRITICAL: Use Absolute Paths for Local Files + +When using a local OpenAPI specification file, you **MUST use an absolute path**: + +```bash +# ✅ CORRECT - Absolute path +--openapi-spec-location /home/user/project/openapi.json + +# ❌ WRONG - Relative path (will fail!) +--openapi-spec-location ./openapi.json +--openapi-spec-location openapi.json +``` + +**Why?** The ATK CLI executes from a temporary directory, so relative paths cannot be resolved. Always use the full absolute path to your OpenAPI specification file. + +### Operation Format + +The `--api-operation` parameter uses the format: `METHOD /path,METHOD /path,...` + +**Example with Repairs API (URL):** +```bash +npx -y --package @microsoft/m365agentstoolkit-cli atk add action \ + --api-plugin-type api-spec \ + --openapi-spec-type enter-url-or-open-local-file \ + --openapi-spec-location "https://repairshub.azurewebsites.net/openapi.json" \ + --api-operation "GET /repairs,GET /repairs/{id},POST /repairs,PATCH /repairs/{id},DELETE /repairs/{id}" \ + -i false +``` + +**Example with local file (absolute path):** +```bash +npx -y --package @microsoft/m365agentstoolkit-cli atk add action \ + --api-plugin-type api-spec \ + --openapi-spec-type enter-url-or-open-local-file \ + --openapi-spec-location /home/user/myproject/nhl-openapi.json \ + --api-operation "GET /v1/standings/now,GET /v1/score/now,GET /v1/schedule/now" \ + -i false +``` + +### What the Command Creates + +After running `npx -y --package @microsoft/m365agentstoolkit-cli atk add action`, the following files are created/updated: + +``` +appPackage/ +├── declarativeAgent.json # Updated with action reference +├── ai-plugin.json # API plugin manifest (generated) +├── adaptiveCards/ # Response templates (auto-generated) +│ ├── getRepairs.json +│ ├── getRepairById.json +│ └── ... +└── apiSpecificationFile/ + ├── openapi.yaml # OpenAPI spec (converted to 3.0 if needed) + └── openapi.yaml.original # Original spec (if converted) +``` + +> **Note:** OpenAPI 3.1 specifications are automatically converted to OpenAPI 3.0 format for compatibility. + +### Post-Generation: Enhance the Plugin + +After running `npx -y --package @microsoft/m365agentstoolkit-cli atk add action`, you **MUST** enhance the generated files: + +1. **Update `name_for_human`** - Make it descriptive and user-friendly +2. **Update `description_for_model`** - Add detailed guidance on when and how to use each function +3. **🎨 Update adaptive cards for each action** - Customize the auto-generated cards in `appPackage/adaptiveCards/` to present meaningful, well-structured data for each operation + +**Example plugin enhancement:** +```json +{ + "name_for_human": "NHL Data API", + "description_for_human": "Access real-time NHL standings, scores, and player statistics", + "description_for_model": "Use this plugin to access real-time NHL data. Call getCurrentStandings for league standings. Call getTodaysScores for today's game scores or getScoresByDate for historical scores (YYYY-MM-DD format). Call getCurrentTeamStats or getCurrentRoster with a 3-letter team code (TOR, BOS, NYR, MTL, EDM, VGK, etc.). Always provide context about the data format and available parameters." +} +``` + +The `description_for_model` is critical - it tells the AI when to use each function and what parameters to provide. + +### 🎨 Post-Generation: Enhance Adaptive Cards for Each Action + +The `npx -y --package @microsoft/m365agentstoolkit-cli atk add action` command auto-generates basic adaptive cards in `appPackage/adaptiveCards/` — one per operation. These default cards are generic and only display raw data. **You MUST customize each card** to provide a valuable UX tailored to the data each action returns. + +#### ⚠️ CRITICAL: Check ALL Operations Have Adaptive Cards + +After running `npx -y --package @microsoft/m365agentstoolkit-cli atk add action`, **verify that an adaptive card exists for EVERY operation**. The command may not generate cards for POST, PATCH, or DELETE operations. **If any operation is missing an adaptive card, CREATE one manually** in `appPackage/adaptiveCards/`. + +- **GET operations** → List or detail layout showing returned data +- **POST operations** → Confirmation/summary layout showing what was created +- **PATCH operations** → Summary layout showing what was updated +- **DELETE operations** → Confirmation layout confirming what was deleted + +#### Why Customize Adaptive Cards? + +- **Default cards are generic** — they list all response fields as plain text with no hierarchy or formatting +- **Users expect rich, scannable output** — titles, subtitles, key metrics, and visual hierarchy help users quickly understand results +- **Different actions need different layouts** — a list of items needs a different card than a single detail view or a confirmation response + +#### How to Enhance Each Card + +For **each** adaptive card file generated in `appPackage/adaptiveCards/`: + +1. **Examine the API response schema** — understand what fields each operation returns +2. **Identify the most important fields** — determine what users care about most (title, status, date, assignee, etc.) +3. **Design a clear visual hierarchy** — use `weight: "Bolder"`, `size: "Medium"/"Large"`, `spacing`, and `separator` to create structure +4. **Use appropriate layouts** — `ColumnSet` for side-by-side data, `FactSet` for key-value pairs, `Container` for grouping +5. **Add conditional styling** — use `"color"` properties and `"style"` to highlight important states (e.g., urgent, overdue) +6. **Include action buttons** — add `Action.OpenUrl` for links to external resources when the data includes URLs +7. **Handle arrays with `$foreach`** — use `${$root}` for the items array and `${$data}` for individual items within the template + +#### Example: Before vs After Enhancement + +**❌ Auto-generated card (generic — poor UX):** +```json +{ + "$schema": "http://adaptivecards.io/schemas/adaptive-card.json", + "type": "AdaptiveCard", + "version": "1.5", + "body": [ + { + "type": "TextBlock", + "text": "id: ${if(id, id, 'N/A')}", + "wrap": true + }, + { + "type": "TextBlock", + "text": "title: ${if(title, title, 'N/A')}", + "wrap": true + }, + { + "type": "TextBlock", + "text": "description: ${if(description, description, 'N/A')}", + "wrap": true + }, + { + "type": "TextBlock", + "text": "assignedTo: ${if(assignedTo, assignedTo, 'N/A')}", + "wrap": true + }, + { + "type": "TextBlock", + "text": "date: ${if(date, date, 'N/A')}", + "wrap": true + }, + { + "type": "TextBlock", + "text": "image: ${if(image, image, 'N/A')}", + "wrap": true + } + ] +} +``` + +**✅ Enhanced card (rich UX — tailored to data):** +```json +{ + "$schema": "http://adaptivecards.io/schemas/adaptive-card.json", + "type": "AdaptiveCard", + "version": "1.5", + "body": [ + { + "type": "ColumnSet", + "columns": [ + { + "type": "Column", + "width": "auto", + "items": [ + { + "type": "Image", + "url": "${image}", + "size": "Medium", + "altText": "${title}" + } + ] + }, + { + "type": "Column", + "width": "stretch", + "items": [ + { + "type": "TextBlock", + "text": "${title}", + "weight": "Bolder", + "size": "Medium", + "wrap": true + }, + { + "type": "TextBlock", + "text": "Assigned to: ${assignedTo}", + "spacing": "None", + "isSubtle": true, + "wrap": true + } + ] + } + ] + }, + { + "type": "TextBlock", + "text": "${description}", + "wrap": true, + "spacing": "Medium" + }, + { + "type": "FactSet", + "separator": true, + "facts": [ + { + "title": "Repair ID", + "value": "#${id}" + }, + { + "title": "Date", + "value": "${date}" + } + ] + } + ] +} +``` + +#### Example: List Action Card (Array Response with `$foreach`) + +When an action returns an array of items, use the `$foreach` data binding pattern to render each item: + +```json +{ + "$schema": "http://adaptivecards.io/schemas/adaptive-card.json", + "type": "AdaptiveCard", + "version": "1.5", + "body": [ + { + "type": "Container", + "$data": "${$root}", + "separator": true, + "items": [ + { + "type": "TextBlock", + "text": "${title}", + "weight": "Bolder", + "wrap": true + }, + { + "type": "ColumnSet", + "columns": [ + { + "type": "Column", + "width": "stretch", + "items": [ + { + "type": "TextBlock", + "text": "Assigned to: ${assignedTo}", + "isSubtle": true, + "spacing": "None", + "wrap": true + } + ] + }, + { + "type": "Column", + "width": "auto", + "items": [ + { + "type": "TextBlock", + "text": "${date}", + "isSubtle": true, + "spacing": "None", + "horizontalAlignment": "Right" + } + ] + } + ] + } + ] + } + ] +} +``` + +#### Card Design Guidelines Per Action Type + +| Action Type | Recommended Layout | Key Elements | +|-------------|-------------------|--------------| +| **List / Search** (GET returning array) | Repeating `Container` with `$data: "${$root}"` | Title, subtitle, key status field per item, separator between items | +| **Get by ID** (GET returning single object) | `ColumnSet` + `FactSet` | Image/icon, title, description, key-value pairs for metadata | +| **Create** (POST) | Confirmation summary | Show created item's key fields, success indicator | +| **Update** (PATCH/PUT) | Before/after or summary | Show updated fields, confirmation status | +| **Delete** (DELETE) | Simple confirmation | Item identifier, success message | + +--- + +## When to Use API Plugins + +Use API plugins (actions) when the agent needs to: +- **Call external APIs** not available through M365 capabilities +- **Perform CRUD operations** (Create, Read, Update, Delete) +- **Access real-time transactional data** from external systems +- **Modify state** in external systems (create tickets, update records) +- **Integrate with line-of-business systems** (CRM, ticketing, databases) + +**Don't use API plugins when:** +- M365 capabilities already provide the data (use capabilities like `OneDriveAndSharePoint`, `Email`, etc.) +- Read-only access to documents is sufficient +- No external API interaction needed + +## API Plugin vs Capabilities Decision Tree + +``` +Need external API? + Yes → Create API Plugin Action + No → Use built-in Capability + ↓ +Need real-time transactional data? + Yes → API Plugin + No → Capability (SharePoint, Connectors) + ↓ +Need CRUD operations? + Yes → API Plugin + No → Capability + ↓ +Need to modify state? + Yes → API Plugin + No → Capability +``` + +--- + +## API Plugin Manifest Structure (v2.4) + +The API plugin manifest is a JSON file that configures how M365 Copilot interacts with your API. + +### Basic Structure + +```json +{ + "schema_version": "v2.4", + "name_for_human": "Plugin Name", + "namespace": "pluginNamespace", + "description_for_human": "What this plugin does for users", + "description_for_model": "Detailed instructions for the AI model on when and how to use this plugin", + "functions": [], + "runtimes": [] +} +``` + +### Complete Example: Repairs API Plugin + +```json +{ + "schema_version": "v2.4", + "name_for_human": "Repairs API", + "namespace": "repairs", + "description_for_human": "Manage repair tickets and track issues", + "description_for_model": "Use this plugin to search, create, update, and delete repair tickets. Call getRepairs to list all repairs or filter by assignee. Call getRepairById to get details about a specific repair. Call createRepair to create new tickets. Call updateRepair to modify existing repairs. Call deleteRepair to remove completed repairs.", + "logo_url": "https://repairshub.azurewebsites.net/logo.png", + "contact_email": "support@contoso.com", + "legal_info_url": "https://contoso.com/terms", + "privacy_policy_url": "https://contoso.com/privacy", + "functions": [ + { + "name": "getRepairs", + "description": "Get all repairs, optionally filtered by assignee", + "capabilities": { + "response_semantics": { + "data_path": "$", + "properties": { + "title": "$.title", + "subtitle": "$.assignedTo" + } + } + } + }, + { + "name": "getRepairById", + "description": "Get a specific repair by its ID" + }, + { + "name": "createRepair", + "description": "Create a new repair ticket", + "capabilities": { + "confirmation": { + "type": "AdaptiveCard", + "title": "Create Repair?", + "body": "Are you sure you want to create this repair ticket?" + } + } + }, + { + "name": "updateRepair", + "description": "Update an existing repair ticket", + "capabilities": { + "confirmation": { + "type": "AdaptiveCard", + "title": "Update Repair?", + "body": "Are you sure you want to update this repair?" + } + } + }, + { + "name": "deleteRepair", + "description": "Delete a repair ticket", + "capabilities": { + "confirmation": { + "type": "AdaptiveCard", + "title": "Delete Repair?", + "body": "Are you sure you want to permanently delete this repair?" + } + } + } + ], + "runtimes": [ + { + "type": "OpenApi", + "auth": { + "type": "None" + }, + "spec": { + "url": "apiSpecificationFile/openapi.json" + }, + "run_for_functions": ["getRepairs", "getRepairById", "createRepair", "updateRepair", "deleteRepair"] + } + ] +} +``` + +--- + +## OpenAPI Specification + +The OpenAPI specification describes your REST API endpoints. Here's an example based on the Repairs API: + +### Example: Repairs API OpenAPI Spec + +```json +{ + "openapi": "3.1.0", + "info": { + "title": "Repairs API", + "description": "A simple service to manage repairs for various items", + "version": "1.0.0" + }, + "servers": [ + { + "url": "https://repairshub.azurewebsites.net/" + } + ], + "paths": { + "/repairs": { + "get": { + "operationId": "getRepairs", + "summary": "Get all repairs", + "parameters": [ + { + "name": "assignedTo", + "in": "query", + "required": false, + "schema": { + "type": "string" + }, + "description": "Filter repairs by assignee name" + } + ], + "responses": { + "200": { + "description": "List of repairs", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Repair" + } + } + } + } + } + } + }, + "post": { + "operationId": "createRepair", + "summary": "Create a new repair", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CreateRepairRequest" + } + } + } + }, + "responses": { + "201": { + "description": "Repair created", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Repair" + } + } + } + } + } + } + }, + "/repairs/{id}": { + "get": { + "operationId": "getRepairById", + "summary": "Get a repair by ID", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Repair found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Repair" + } + } + } + }, + "404": { + "description": "Repair not found" + } + } + }, + "patch": { + "operationId": "updateRepair", + "summary": "Update a repair by ID", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UpdateRepairRequest" + } + } + } + }, + "responses": { + "200": { + "description": "Repair updated", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Repair" + } + } + } + } + } + }, + "delete": { + "operationId": "deleteRepair", + "summary": "Delete a repair by ID", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Repair deleted" + } + } + } + } + }, + "components": { + "schemas": { + "Repair": { + "type": "object", + "properties": { + "id": { "type": "number" }, + "title": { "type": "string" }, + "description": { "type": "string" }, + "assignedTo": { "type": "string" }, + "date": { "type": "string" }, + "image": { "type": "string" } + }, + "required": ["id", "title", "description", "assignedTo", "date", "image"] + }, + "CreateRepairRequest": { + "type": "object", + "properties": { + "title": { "type": "string" }, + "description": { "type": "string" }, + "assignedTo": { "type": "string" }, + "date": { "type": "string" }, + "image": { "type": "string" } + }, + "required": ["title", "description", "assignedTo", "date", "image"] + }, + "UpdateRepairRequest": { + "type": "object", + "properties": { + "title": { "type": "string" }, + "description": { "type": "string" }, + "assignedTo": { "type": "string" }, + "date": { "type": "string" }, + "image": { "type": "string" } + } + } + } + } +} +``` + +--- + +## Linking API Plugin to Declarative Agent + +Reference the API plugin in your `declarativeAgent.json`: + +```json +{ + "version": "v1.6", + "name": "Repairs Agent", + "description": "An agent to manage repair tickets", + "instructions": "You help users manage repair tickets. Use the repairs API to search, create, update, and delete repairs.", + "actions": [ + { + "id": "repairsPlugin", + "file": "plugins/repairs-plugin.json" + } + ], + "conversation_starters": [ + { + "title": "My Repairs", + "text": "What repairs are assigned to me?" + }, + { + "title": "Create Repair", + "text": "I need to create a new repair ticket" + } + ] +} +``` + +--- + +## Authentication for API Plugins + +### No Authentication + +For public APIs or development: + +```json +{ + "runtimes": [ + { + "type": "OpenApi", + "auth": { + "type": "None" + }, + "spec": { + "url": "apiSpecificationFile/openapi.json" + } + } + ] +} +``` + +### OAuth Authentication + +For OAuth2-protected APIs: + +```json +{ + "runtimes": [ + { + "type": "OpenApi", + "auth": { + "type": "OAuthPluginVault", + "reference_id": "your-oauth-reference-id" + }, + "spec": { + "url": "apiSpecificationFile/openapi.json" + } + } + ] +} +``` + +### API Key Authentication + +For API key-protected APIs: + +```json +{ + "runtimes": [ + { + "type": "OpenApi", + "auth": { + "type": "ApiKeyPluginVault", + "reference_id": "your-apikey-reference-id" + }, + "spec": { + "url": "apiSpecificationFile/openapi.json" + } + } + ] +} +``` + +> **Note:** The `reference_id` is obtained when configuring authentication in the Microsoft 365 admin center or through the ATK CLI during provisioning. + +--- + +## API Plugin Best Practices + +### 1. Operation Naming in OpenAPI + +Use clear `operationId` values that describe the action: + +```json +{ + "get": { + "operationId": "searchTickets", + "summary": "Search for tickets by keyword or status" + }, + "post": { + "operationId": "createTicket", + "summary": "Create a new ticket" + } +} +``` + +✅ Good: `searchTickets`, `createTicket`, `updateTicketStatus` +❌ Bad: `tickets`, `doSomething`, `api1` + +### 2. Descriptive Function Entries + +Provide detailed descriptions in the plugin manifest: + +```json +{ + "functions": [ + { + "name": "searchTickets", + "description": "Search for support tickets. Use this when the user asks about finding tickets, checking ticket status, or looking up issues. Supports filtering by status (open, closed, pending) and assignee." + } + ] +} +``` + +### 3. Add Confirmations for Destructive Operations + +Always add confirmation dialogs for create, update, and delete operations: + +```json +{ + "functions": [ + { + "name": "deleteTicket", + "description": "Permanently delete a ticket", + "capabilities": { + "confirmation": { + "type": "AdaptiveCard", + "title": "Delete Ticket?", + "body": "This action cannot be undone. Are you sure you want to delete this ticket?" + } + } + } + ] +} +``` + +### 4. Use Response Semantics for Rich Display + +Configure how results are displayed to users: + +```json +{ + "functions": [ + { + "name": "getRepairs", + "description": "Get all repairs", + "capabilities": { + "response_semantics": { + "data_path": "$", + "properties": { + "title": "$.title", + "subtitle": "$.assignedTo", + "url": "$.url" + }, + "static_template": { + "$schema": "http://adaptivecards.io/schemas/adaptive-card.json", + "type": "AdaptiveCard", + "version": "1.5", + "body": [ + { + "type": "TextBlock", + "text": "${title}", + "weight": "Bolder", + "size": "Medium" + }, + { + "type": "TextBlock", + "text": "Assigned to: ${assignedTo}", + "spacing": "None" + }, + { + "type": "TextBlock", + "text": "${description}", + "wrap": true + } + ] + } + } + } + } + ] +} +``` + +--- + +## Common API Plugin Patterns + +### Pattern: CRM Integration + +**Plugin Manifest:** +```json +{ + "schema_version": "v2.4", + "name_for_human": "CRM API", + "namespace": "crm", + "description_for_human": "Manage customer accounts and opportunities", + "description_for_model": "Use this plugin to search for customer accounts, view account details, list opportunities, and update opportunity status in the CRM system.", + "functions": [ + { + "name": "searchAccounts", + "description": "Search for customer accounts by name or industry" + }, + { + "name": "getAccount", + "description": "Get detailed information about a specific account" + }, + { + "name": "listOpportunities", + "description": "List sales opportunities, optionally filtered by account or stage" + }, + { + "name": "updateOpportunityStage", + "description": "Update the stage of a sales opportunity", + "capabilities": { + "confirmation": { + "type": "AdaptiveCard", + "title": "Update Opportunity?", + "body": "Update the opportunity stage?" + } + } + } + ], + "runtimes": [ + { + "type": "OpenApi", + "auth": { + "type": "OAuthPluginVault", + "reference_id": "crm-oauth-ref" + }, + "spec": { + "url": "apiSpecificationFile/crm-openapi.json" + } + } + ] +} +``` + +### Pattern: Ticketing System + +**Plugin Manifest:** +```json +{ + "schema_version": "v2.4", + "name_for_human": "Ticket System", + "namespace": "tickets", + "description_for_human": "Manage support tickets and track issues", + "description_for_model": "Use this plugin to search support tickets, create new tickets, add comments, and update ticket status. Always confirm with the user before creating or modifying tickets.", + "functions": [ + { + "name": "searchTickets", + "description": "Search for tickets by keyword, status, or assignee", + "capabilities": { + "response_semantics": { + "data_path": "$.tickets", + "properties": { + "title": "$.title", + "subtitle": "$.status" + } + } + } + }, + { + "name": "getTicket", + "description": "Get detailed information about a specific ticket including comments and history" + }, + { + "name": "createTicket", + "description": "Create a new support ticket", + "capabilities": { + "confirmation": { + "type": "AdaptiveCard", + "title": "Create Ticket?", + "body": "Create a new support ticket?" + } + } + }, + { + "name": "addComment", + "description": "Add a comment to an existing ticket", + "capabilities": { + "confirmation": { + "type": "AdaptiveCard", + "title": "Add Comment?", + "body": "Add this comment to the ticket?" + } + } + }, + { + "name": "updateTicketStatus", + "description": "Update the status of a ticket (open, in-progress, resolved, closed)", + "capabilities": { + "confirmation": { + "type": "AdaptiveCard", + "title": "Update Status?", + "body": "Update the ticket status?" + } + } + } + ], + "runtimes": [ + { + "type": "OpenApi", + "auth": { + "type": "ApiKeyPluginVault", + "reference_id": "tickets-apikey-ref" + }, + "spec": { + "url": "apiSpecificationFile/tickets-openapi.yaml" + } + } + ] +} +``` + +### Pattern: Analytics/Reporting + +**Plugin Manifest:** +```json +{ + "schema_version": "v2.4", + "name_for_human": "Analytics API", + "namespace": "analytics", + "description_for_human": "Query business metrics and generate reports", + "description_for_model": "Use this plugin to retrieve business metrics, run analytics queries, and generate reports. This is a read-only API for data analysis.", + "functions": [ + { + "name": "getMetrics", + "description": "Get key business metrics for a specified date range" + }, + { + "name": "runQuery", + "description": "Execute a custom analytics query with filters and dimensions" + }, + { + "name": "getReport", + "description": "Get a pre-built report by name or ID" + } + ], + "runtimes": [ + { + "type": "OpenApi", + "auth": { + "type": "OAuthPluginVault", + "reference_id": "analytics-oauth-ref" + }, + "spec": { + "url": "apiSpecificationFile/analytics-openapi.json" + } + } + ] +} +``` + +--- + +## Testing API Plugins + +### 1. Validate OpenAPI Specification + +Before adding to your agent, validate your OpenAPI spec: + +```bash +# Using online validator +# Visit https://editor.swagger.io and paste your spec + +# Or use a local tool +npx @apidevtools/swagger-cli validate openapi.json +``` + +### 2. Test API Endpoints Directly + +Use tools like Bruno, Postman, or REST Client extension to test your API: + +```http +### Get all repairs +GET https://repairshub.azurewebsites.net/repairs + +### Get repair by ID +GET https://repairshub.azurewebsites.net/repairs/1 + +### Create a new repair +POST https://repairshub.azurewebsites.net/repairs +Content-Type: application/json + +{ + "title": "Fix broken laptop", + "description": "Screen is cracked", + "assignedTo": "John Doe", + "date": "2026-01-20", + "image": "https://example.com/image.jpg" +} +``` + +### 3. Provision and Test in M365 Copilot + +```bash +# Provision the agent +npx -y --package @microsoft/m365agentstoolkit-cli atk provision --env local --interactive false + +# Use the returned test URL to test in M365 Copilot +``` + +Test with various prompts: +- "Show me all repairs" +- "Find repairs assigned to John" +- "Create a new repair for a broken laptop" +- "Update repair #1 to assign it to Jane" + +--- + +## Security Considerations + +### 1. Credential Management +- **Never hardcode credentials** in JSON files +- Use `reference_id` for OAuth and API key authentication +- Store secrets in environment variables or secure vaults +- Rotate credentials regularly + +### 2. Input Validation +- Validate all inputs in your API backend +- Prevent injection attacks +- Limit string lengths and ranges +- Use strong typing in OpenAPI schemas + +### 3. Confirmation Dialogs +- Always add confirmations for state-changing operations +- Be clear about what action will be taken +- Allow users to cancel destructive operations + +### 4. Rate Limiting +- Implement rate limiting in your API backend +- Handle 429 (Too Many Requests) responses gracefully +- Consider caching for frequently-accessed data + +### 5. Data Privacy +- Only request necessary data from your API +- Follow data residency requirements +- Log API calls for audit purposes +- Handle PII appropriately + +--- + +## Common Issues and Solutions + +| Issue | Cause | Solution | +|-------|-------|----------| +| "Function not found" | `operationId` in OpenAPI doesn't match `name` in functions | Ensure function names match OpenAPI operationIds | +| Authentication fails | Invalid or expired `reference_id` | Re-register authentication in admin center | +| CORS errors | API doesn't allow agent origin | Configure API CORS to allow M365 origins | +| Timeout errors | API response too slow | Optimize API, add caching, increase timeout | +| Schema mismatch | Plugin expects different response format | Update OpenAPI spec to match actual API response | +| "No operations selected" | Empty `--api-operation` parameter | Specify operations in correct format: `METHOD /path` | +| "File not found" during `npx -y --package @microsoft/m365agentstoolkit-cli atk add action` | Relative path used for local OpenAPI spec | **Use absolute path**: `/full/path/to/openapi.json` | +| "File not found in zip archive" during provision | Manual plugin creation with incorrect spec path | **Use `npx -y --package @microsoft/m365agentstoolkit-cli atk add action` command** - do not manually create plugins | +| OpenAPI spec path resolution fails | Path conflicts between zipAppPackage and M365 service | The ATK CLI handles this correctly - always use `npx -y --package @microsoft/m365agentstoolkit-cli atk add action` | + +### Why Manual Plugin Creation Fails + +If you try to manually create API plugin files, you'll encounter path resolution conflicts: + +1. **zipAppPackage** resolves spec paths relative to the plugin file location +2. **M365 extendToM365** resolves spec paths relative to the zip archive root + +These different resolution strategies make manual path configuration nearly impossible. The `npx -y --package @microsoft/m365agentstoolkit-cli atk add action` command handles this complexity automatically by: +- Placing the OpenAPI spec in `apiSpecificationFile/` +- Using the correct relative path `apiSpecificationFile/openapi.yaml` in the plugin +- Ensuring the zip archive structure matches the path expectations + +--- + +## Authentication for API Plugins + +The examples above use `"auth": {"type": "None"}` (unauthenticated APIs). If your API requires OAuth authentication, see [authentication.md](authentication.md) for: + +- Discovering OAuth endpoints from well-known metadata +- Obtaining client credentials (via Dynamic Client Registration or manual entry) +- Configuring the `oauth/register` lifecycle step in `m365agents.yml` +- Using `OAuthPluginVault` in the plugin manifest runtime block + +The `oauth/register` step must be added to `m365agents.yml` before `teamsApp/zipAppPackage`. The resulting `_MCP_AUTH_ID` environment variable is referenced in the plugin's runtime auth block. + +--- + +## Related Documentation + +- [Authentication Guide](authentication.md) +- [Plugin Manifest Schema v2.4](https://raw.githubusercontent.com/MicrosoftDocs/m365copilot-docs/refs/heads/main/docs/plugin-manifest-2.4.md) +- [OpenAPI Specification](https://www.openapis.org/what-is-openapi) +- [Repairs API Example](https://repairshub.azurewebsites.net/openapi.json) +- [JSON Schema Reference](schema.md) diff --git a/plugins/microsoft-365-agents-toolkit/skills/declarative-agent-developer/references/authentication.md b/plugins/microsoft-365-agents-toolkit/skills/declarative-agent-developer/references/authentication.md new file mode 100644 index 0000000..f4451f1 --- /dev/null +++ b/plugins/microsoft-365-agents-toolkit/skills/declarative-agent-developer/references/authentication.md @@ -0,0 +1,265 @@ +# OAuth Authentication for M365 Agent Plugins + +This guide explains how to configure OAuth authentication for MCP server plugins and API plugins in your M365 Copilot agent. It covers endpoint discovery, credential acquisition, PKCE, and the `oauth/register` lifecycle step in `m365agents.yml`. + +> **When to use this guide:** +> - Your MCP server requires OAuth authentication (most third-party MCP servers do) +> - Your API plugin requires OAuth (not just API key auth) +> - You need to register OAuth credentials in the Teams Developer Portal via ATK + +> **When NOT to use this guide:** +> - The MCP server or API is unauthenticated → use `"auth": {"type": "None"}` directly +> - You're using API key authentication → handle via environment variables in the OpenAPI spec + +--- + +## Overview + +Authenticated plugins use a three-part setup: + +1. **Discover** OAuth endpoints from the server's well-known metadata +2. **Obtain** client credentials (via Dynamic Client Registration or manual entry) +3. **Register** the OAuth configuration in `m365agents.yml` so ATK provisions it in the Teams Developer Portal + +The result is a `_MCP_AUTH_ID` environment variable that the plugin manifest references via `OAuthPluginVault`. + +--- + +## Step 1: OAuth Endpoint Discovery + +Attempt to auto-discover OAuth endpoints from the server's well-known metadata. Try **both** URLs in parallel: + +``` +GET /.well-known/oauth-authorization-server +GET /.well-known/openid-configuration +``` + +Where `` is the scheme + host of the server URL (e.g., `https://mcp.example.com`). + +### Field Mapping + +| Plugin field | Well-known field | +|---|---| +| `authorizationUrl` | `authorization_endpoint` | +| `tokenUrl` | `token_endpoint` | +| `refreshUrl` | `token_endpoint` (same endpoint handles refresh grants) | +| `scope` | `scopes_supported` → join with comma (e.g., `"openid,email,profile"`). If no scopes are discovered or provided, default to `"openid"`. **If `scope` has no value, it MUST be quoted as `""`** — a bare `scope:` with no value is YAML null, not an empty string, and will fail schema validation. | + +### If discovered + +Show the values to the user and confirm: + +> "I found the following OAuth endpoints for [name]. Shall I use these? +> - Authorization URL: ... +> - Token URL: ... +> - Refresh URL: ... +> - Scopes: ..." + +### If not discovered + +Ask the user to provide the four values. If the user doesn't have them, offer: + +> "I can search for these values online — shall I proceed?" + +Only search if the user confirms. Show results and confirm before using. + +--- + +## Step 2: Client Credentials + +### Dynamic Client Registration (DCR) + +First, check if `registration_endpoint` is present in the well-known metadata from Step 1. + +**If `registration_endpoint` is present → attempt DCR automatically:** + +```bash +curl -s -X POST \ + -H "Content-Type: application/json" \ + -d '{ + "client_name": " M365 Connector", + "redirect_uris": ["https://teams.microsoft.com/api/platform/v1.0/oAuthRedirect"], + "grant_types": ["authorization_code", "refresh_token"], + "response_types": ["code"], + "token_endpoint_auth_method": "client_secret_basic", + "scope": "" + }' +``` + +- If the response contains `client_id` and `client_secret` → use them directly. Tell the user credentials were obtained via dynamic registration. **Do NOT ask the user for credentials.** +- If DCR returns an error or no `client_secret` → fall through to manual entry below. + +### Manual Credential Entry + +**If `registration_endpoint` is absent OR DCR fails → ask the user:** + +> "Please provide your OAuth client credentials for [name]: +> - Client ID: +> - Client Secret:" + +### PKCE + +After obtaining credentials (whether via DCR or manual entry), ask the user: + +> "Would you like to enable PKCE (Proof Key for Code Exchange) for this connector? (yes/no)" + +- If the user says yes → `isPKCEEnabled: true` +- If the user says no, or asks you to decide → `isPKCEEnabled: false` + +### ⛔ Security Rules + +- **NEVER** print, display, or reveal access tokens, bearer tokens, or client secrets in your output +- **NEVER** write secrets to any file — they are passed as OS environment variables at provision time only +- Treat `client_secret` as sensitive — store it only in `.env.*.user` files (which are gitignored) + +--- + +## Step 3: Register in `m365agents.yml` and `m365agents.local.yml` + +**⛔ CRITICAL:** You MUST add the `oauth/register` step to BOTH `m365agents.yml` AND `m365agents.local.yml`. Both files need identical `oauth/register` blocks — if you only update one, authentication will fail in that environment. + +Add the `oauth/register` step to the `provision` lifecycle in both files, after `teamsApp/create` and before `teamsApp/zipAppPackage`: + +```yaml +provision: + - uses: teamsApp/create + with: + name: ${{APP_NAME_SUFFIX}} + writeToEnvironmentFile: + teamsAppId: TEAMS_APP_ID + + - uses: oauth/register + with: + name: -oauth + appId: ${{TEAMS_APP_ID}} + clientId: ${{_MCP_CLIENT_ID}} + clientSecret: ${{_MCP_CLIENT_SECRET}} + authorizationUrl: + tokenUrl: + refreshUrl: + scope: + # ⚠️ If scope has no value, use `scope: ""` (quoted empty string). + # A bare `scope:` is YAML null and will fail schema validation. + flow: authorizationCode + identityProvider: Custom + isPKCEEnabled: + tokenExchangeMethodType: PostRequestBody + baseUrl: + writeToEnvironmentFile: + configurationId: _MCP_AUTH_ID + + - uses: teamsApp/zipAppPackage + with: + manifestPath: ./appPackage/manifest.json + outputZipPath: ./appPackage/build/appPackage.zip + outputFolder: ./appPackage/build + + - uses: teamsApp/update + with: + appPackagePath: ./appPackage/build/appPackage.zip +``` + +### Naming Conventions + +| Value | Derivation | Example | +|---|---|---| +| `` | Uppercase slug, hyphens/spaces → underscores | `CANVA_V1`, `HUBSPOT` | +| `` | Display name lowercased, spaces → hyphens | `canva-v1`, `hubspot` | +| `name` in oauth/register | `-oauth` | `canva-v1-oauth` | +| `_MCP_CLIENT_ID` | Client ID env var | `CANVA_V1_MCP_CLIENT_ID` | +| `_MCP_CLIENT_SECRET` | Client secret env var | `CANVA_V1_MCP_CLIENT_SECRET` | +| `_MCP_AUTH_ID` | Auth config ID (written by provision) | `CANVA_V1_MCP_AUTH_ID` | + +### Environment Files + +**`env/.env.dev`** (committed, no secrets): +``` +TEAMS_APP_ID= +_MCP_AUTH_ID= +APP_NAME_SUFFIX=-dev +TEAMSFX_ENV=dev +``` + +**`env/.env.dev.user`** (gitignored, contains secrets): +``` +_MCP_CLIENT_ID= +_MCP_CLIENT_SECRET= +``` + +> **Important:** Add `_MCP_AUTH_ID=` to `.env.dev` as soon as you detect the server requires OAuth — before running provision. The `oauth/register` step will populate its value during provisioning. +> +> **⛔ NEVER set a placeholder value** for `_MCP_AUTH_ID` (e.g., `PLACEHOLDER`, `TODO`, `temp`). Leave it empty (`_MCP_AUTH_ID=`). The `oauth/register` automation will write the real value during provisioning. If a placeholder is present, it will be treated as the actual value and will NOT be overwritten. + +--- + +## Step 4: Plugin Manifest Auth Block + +In the plugin manifest's `runtimes[]` entry, reference the registered OAuth configuration: + +### Authenticated (OAuthPluginVault) + +```json +{ + "type": "RemoteMCPServer", + "auth": { + "type": "OAuthPluginVault", + "reference_id": "${{_MCP_AUTH_ID}}" + }, + "spec": { + "url": "", + "mcp_tool_description": { + "tools": [ ... ] + } + }, + "run_for_functions": [ ... ] +} +``` + +### Unauthenticated (None) + +```json +{ + "type": "RemoteMCPServer", + "auth": { + "type": "None" + }, + "spec": { + "url": "", + "mcp_tool_description": { + "tools": [ ... ] + } + }, + "run_for_functions": [ ... ] +} +``` + +--- + +## Decision Tree + +Use this decision tree to determine the authentication flow: + +``` +MCP server URL provided +│ +├── Probe /.well-known/oauth-authorization-server +│ AND /.well-known/openid-configuration +│ +├── OAuth metadata found? +│ ├── YES → Step 1 (map endpoints) → Step 2 (DCR or manual creds) → Step 3 (oauth/register) → Step 4 (OAuthPluginVault) +│ └── NO → Use "auth": {"type": "None"} — skip Steps 1-3 +│ +└── For API plugins: same flow applies — add oauth/register to m365agents.yml if OAuth is needed +``` + +--- + +## Common Issues + +| Issue | Solution | +|---|---| +| `registration_endpoint` returns 404 | DCR not supported — ask user for credentials manually | +| Token refresh fails | Verify `refreshUrl` matches `token_endpoint` from well-known metadata | +| `_MCP_AUTH_ID` empty after provision | Check that `oauth/register` step is in `m365agents.yml` and credentials are correct | +| "Invalid redirect URI" during OAuth | Ensure redirect URI is exactly `https://teams.microsoft.com/api/platform/v1.0/oAuthRedirect` | +| PKCE errors | Some providers don't support PKCE — set `isPKCEEnabled: false` | diff --git a/plugins/microsoft-365-agents-toolkit/skills/declarative-agent-developer/references/best-practices.md b/plugins/microsoft-365-agents-toolkit/skills/declarative-agent-developer/references/best-practices.md new file mode 100644 index 0000000..9f662a7 --- /dev/null +++ b/plugins/microsoft-365-agents-toolkit/skills/declarative-agent-developer/references/best-practices.md @@ -0,0 +1,72 @@ +# M365 Agent Developer Best Practices + +Follow these best practices for successful M365 Copilot agent development. + +## Security + +- **Principle of Least Privilege:** Always scope capabilities to the minimum necessary resources +- **Credential Management:** Use secure credential storage for production environments +- **Input Validation:** Validate all user inputs and API responses +- **PII Handling:** Follow data protection regulations when handling personal information +- **Audit Logging:** Implement comprehensive audit trails for all agent actions +- **Secret Storage:** Never hardcode credentials; use Azure Key Vault or environment variables + +## Performance + +- **Scoped Queries:** Use scoped capabilities to reduce query time and improve response quality +- **Efficient API Design:** Design API plugins with pagination and filtering +- **Caching Strategy:** Implement appropriate caching for frequently accessed data +- **Response Time:** Keep operations under 30 seconds to avoid timeouts +- **Batch Operations:** Use batch APIs when processing multiple items + +## Error Handling + +- **Graceful Degradation:** Handle errors without breaking the conversation flow +- **Clear Error Messages:** Provide actionable error messages to users +- **Retry Logic:** Implement retry mechanisms for transient failures +- **Fallback Behavior:** Define fallback behavior when capabilities are unavailable +- **Error Logging:** Log errors with sufficient context for troubleshooting + +## Testing + +- **Test All Conversation Starters:** Verify each starter works as intended +- **Test Edge Cases:** Test with missing data, invalid inputs, and error conditions +- **Security Testing:** Verify scoping and permission controls +- **Cross-Environment Testing:** Test in dev, staging, and production environments +- **User Acceptance Testing:** Conduct UAT with actual users before production release + +## Compliance + +- **Data Residency:** Consider data residency requirements for multi-region deployments +- **Retention Policies:** Follow organizational data retention policies +- **Access Controls:** Implement role-based access controls (RBAC) +- **Compliance Frameworks:** Follow relevant frameworks (GDPR, HIPAA, SOC 2, etc.) +- **Documentation:** Maintain compliance documentation and audit trails + +## Maintainability + +- **Documentation:** Add `@doc` decorators to all operations, models, and properties +- **Naming Conventions:** Use PascalCase for models/enums, camelCase for properties/actions +- **Code Organization:** Separate concerns (capabilities, API plugins, models) +- **Version Control:** Use semantic versioning for shared agents +- **Change Management:** Document changes and maintain changelog + +## Conversation Design + +- **Specific Instructions:** Write directive instructions with clear role definition +- **Actionable Starters:** Create 3-5 specific, actionable conversation starters +- **Clear Boundaries:** Define what the agent can and cannot do +- **Appropriate Tone:** Match tone to audience and context +- **Confirmation Patterns:** Require confirmation for destructive or sensitive actions + +**Reference:** [conversation-design.md](conversation-design.md) + +## Deployment + +- **Environment Strategy:** Use separate environments for dev, staging, and production +- **CI/CD Integration:** Automate testing and deployment using ATK CLI +- **Version Management:** Bump versions before re-provisioning shared agents +- **Rollback Plan:** Have a rollback strategy for failed deployments +- **Monitoring:** Implement monitoring and alerting for production agents + +**Reference:** [deployment.md](deployment.md) diff --git a/plugins/microsoft-365-agents-toolkit/skills/declarative-agent-developer/references/conversation-design.md b/plugins/microsoft-365-agents-toolkit/skills/declarative-agent-developer/references/conversation-design.md new file mode 100644 index 0000000..fb06599 --- /dev/null +++ b/plugins/microsoft-365-agents-toolkit/skills/declarative-agent-developer/references/conversation-design.md @@ -0,0 +1,398 @@ +# Conversation and Instruction Design for M365 Agents + +> Based on the official Microsoft guidance: [Write effective instructions for declarative agents](https://learn.microsoft.com/en-us/microsoft-365-copilot/extensibility/declarative-agent-instructions) + +## Agent Instructions + +The `instructions` field in `declarativeAgent.json` references an external `instructions.txt` file. This keeps the JSON manifest clean and makes instructions easy to edit. This is the most critical aspect of agent design. + +### Instruction Limits and Token Budget + +**Instructions are limited to 8,000 characters.** Every character counts — be concise and deliberate about what goes into `instructions.txt`. + +**⛔ Do NOT duplicate tool/capability metadata in instructions.** Tool names, descriptions, parameters, and schemas are already available to the orchestrator through `ai-plugin.json` (`description_for_model`), MCP plugin manifests (`mcp_tool_description.tools[]`), and capability configuration in the agent manifest. Listing them in instructions wastes the 8,000-character budget. + +Instructions should contain **decision logic only**: WHEN to use each capability, HOW to chain them, and WHAT to do on failure. See [Instruction Review](instruction-review.md) for the full anti-pattern catalog. + +### Instruction Structure + +In `declarativeAgent.json`, reference the instructions file using `${{file:instructions.txt}}`: + +```json +{ + "instructions": "${{file:instructions.txt}}" +} +``` + +Then create `appPackage/instructions.txt` with the actual instructions in Markdown format. + +--- + +## Instruction Components + +A well-structured set of instructions ensures the agent understands its role, the tasks it should perform, and how to interact with users. The main components are: + +**Required:** +- **Purpose** — What goal must the agent accomplish? +- **General guidelines** — Tone, restrictions, and general directions +- **Skills** — What capabilities and actions does the agent have? + +**When relevant:** +- Step-by-step instructions +- Error handling and limitations +- Interaction examples +- Nonstandard terms / domain vocabulary +- Follow-up and closing behavior + +--- + +## Best Practices for Instructions + +### 1. Use Clear, Actionable Language + +- **Focus on what Copilot should do**, not what to avoid. +- **Use precise, specific verbs**: "ask", "search", "send", "check", "use". +- **Supplement with examples** to minimize ambiguity. +- **Define any terms** that are nonstandard or unique to the organization. + +❌ **Vague**: "Help users with their questions" +✅ **Specific**: "Answer questions about company policies using documents from the HR SharePoint site. If the answer isn't in the documents, direct users to hr@company.com" + +### 2. Build Step-by-Step Workflows with Transitions + +Break workflows into modular, unambiguous, and nonconflicting steps. Each step should include: + +- **Goal**: The purpose of the step. +- **Action**: What the agent should do and which tools to use. +- **Transition**: Clear criteria for moving to the next step or ending the workflow. + +### 3. Use Strict Structure + +Structure is one of the strongest signals used to interpret intent: + +- Use **sections** to group related tasks into logical categories, without implying sequence. +- Use **bullets** for parallel tasks that can be completed independently. Avoid numbering that might introduce unintended order. +- Use **steps** for actions that must occur in a required sequence, and reserve them only for true workflows. + +### 4. Make Tasks Atomic + +Break multiaction instructions into clearly separated units. + +- Instead of: "Extract metrics and summarize findings." +- Use separate steps: + 1. Extract metrics. + 2. Summarize findings. + +### 5. Specify Tone, Verbosity, and Output Format + +If you don't specify these, the model might infer them inconsistently. Always specify: + +- **Tone**: professional and concise +- **Output**: Three bullet points per section +- **Constraint**: Return only the requested format; no explanations + +### 6. Structure Instructions in Markdown + +Use [Markdown](https://www.markdownguide.org/basic-syntax) for emphasis and clarity: + +- Use `#`, `##`, `###` for section headers +- Use `-` for unordered lists and `1.` for numbered lists +- Highlight tool or system names with backticks (e.g., `Jira`, `ServiceNow`) +- Make critical instructions bold with `**` + +### 7. Provide Clear Intent for Each Data Source + +Describe the purpose and context for each data source the agent can use. You don't need to use the exact capability names from the manifest — M365 Copilot maps them internally. What matters is clear intent: **when** and **why** each data source should be used. + +- **Actions/plugins**: Reference by name — "Use `Jira` to fetch tickets." +- **Copilot connector knowledge**: Reference by connector name — "Use `ServiceNow KB` for help articles." +- **SharePoint/OneDrive**: Describe the data source — "Search internal HR policy documents" or "Reference company documents in SharePoint." +- **Email**: Describe the scenario — "Check user emails for relevant information." +- **Teams messages**: "Search Teams channels and chat messages." (messages only — NOT transcripts) +- **Meetings**: "Check calendar events, attendees, and meeting transcripts." (transcripts come from `Meetings`, not `TeamsMessages`) +- **Code interpreter**: "Use code interpreter to generate charts." +- **People knowledge**: "Look up people in the organization for contact info." + +> **Common mistake:** Assuming meeting transcripts are part of `TeamsMessages`. Transcripts are retrieved through the `Meetings` capability. `TeamsMessages` covers channel posts, DMs, and meeting chat messages only. See the [Capability Reference](instruction-review.md#capability-reference-v16) for the full mapping. + +### 8. Provide Examples + +- For simple scenarios, examples aren't needed. +- For complex scenarios, use **few-shot prompting** — give more than one example to illustrate different aspects or edge cases. + +### 9. Control Reasoning Through Phrasing + +Your wording signals how much reasoning the model should apply: + +**Deep reasoning:** +```md +Use deep reasoning. Break the problem into steps, analyze each step, evaluate alternatives, and justify the final decision. Reflect before answering. +Task: Determine the optimal 3-year migration strategy given constraints A, B, and C. +``` + +**Moderate reasoning (balanced):** +```md +Provide a concise but structured explanation. Include a short summary, 3 key drivers, and a final recommendation. No step-by-step reasoning required. +Task: Explain the tradeoffs between solution X and Y. +``` + +**Fast and minimal reasoning:** +```md +Short answer only. No reasoning or explanation. Provide the final result only. +Task: Extract the product name and renewal date from this paragraph. +``` + +### 10. Add a Self-Evaluation Step + +A self-check step reinforces completeness. For example: + +> Before finalizing, confirm that all items from Section A appear in the summary. + +### 11. Iterate on Your Instructions + +Developing instructions is an iterative process: + +1. **Create** instructions and conversation starters following this guidance. +2. **Publish** your agent. +3. **Test** your agent: + - Compare results against Microsoft 365 Copilot. + - Verify conversation starters work as expected. + - Verify the agent acts according to instructions. + - Confirm prompts outside conversation starters are handled appropriately. +4. **Iterate** on instructions to further improve output. + +--- + +## Example Instructions + +The following example is for an IT help desk agent: + +**`appPackage/instructions.txt`:** +```md +# OBJECTIVE +Guide users through issue resolution by gathering information, checking outages, narrowing down solutions, and creating tickets if needed. Ensure the interaction is focused, friendly, and efficient. + +# RESPONSE RULES +- Ask one clarifying question at a time, only when needed. +- Present information as concise bullet points or tables. +- Avoid overwhelming users with details or options. +- Always confirm before moving to the next step or ending. +- Use tools only if data is sufficient; otherwise, ask for missing info. + +# WORKFLOW + +## Step 1: Gather Basic Details +- **Goal:** Identify the user's issue. +- **Action:** + - Proceed if the description is clear. + - If unclear, ask a single, focused clarifying question. + - Example: + User: "Issue accessing a portal." + Assistant: "Which portal?" +- **Transition:** Once clear, proceed to Step 2. + +## Step 2: Check for Ongoing Outages +- **Goal:** Rule out known outages. +- **Action:** + - Query `ServiceNow` for current outages. + - If an outage is found: + - Share details and ETA. + - Ask: "Is your issue unrelated? If yes, I can help further." + - If yes, go to Step 3. If no/no response, end politely. + - If none, inform the user and go to Step 3. + +## Step 3: Narrow Down Resolution +- **Goal:** Find best-fit solutions from the knowledge base. +- **Action:** + - Search `ServiceNow KB` for related articles. + - **Iterative narrowing:** Don't list all results. Instead: + - Ask clarifying questions based on article differences. + - Eliminate irrelevant options with user responses. + - Repeat until the best solution is found. + - Provide step-by-step fix instructions. + - Confirm: "Did this help? If not, I can go deeper or create a ticket." + +## Step 4: Create Support Ticket +- **Goal:** Log unresolved issues. +- **Action:** + 1. Map category and subcategory from the `sys_choice` SharePoint file. + 2. Fetch user's UPN (email) with the people capability. + 3. Fill the ticket with: Caller ID, Category, Subcategory, Description, attempted steps, error codes. +- **Transition:** Confirm ticket creation and next steps. + +# OUTPUT FORMATTING RULES +- Use bullets for actions, lists, next steps. +- Use tables for structured data where UI allows. +- Avoid long paragraphs; keep responses skimmable. +- Always confirm before ending or submitting tickets. +``` + +--- + +## Instruction Design Patterns + +### Pattern 1: Deterministic Workflows + +Remove ambiguity by defining atomic steps, explicit formulas, and required validation: + +```md +## Task: Metrics and ROI (Deterministic) + +### Definitions (Do not invent) +- Metrics to compute: [Metric1], [Metric2], [Metric3] +- ROI definition: ROI = (Benefit - Cost) / Cost +- Source of truth: Use ONLY the provided document(s) for inputs + +### Steps (Sequential — do not reorder) +Step 1: Locate inputs for [Metric1-3] in the document. Quote the source. +Step 2: Compute [Metric1-3] exactly as defined. If any input is missing, stop and ask. +Step 3: Compute ROI using the definition above. Do not substitute other formulas. +Step 4: Output ONLY the table in the format below. + +### Final check (Self-evaluation) +Before finalizing: confirm every metric has (a) a value, (b) a source, and (c) no assumptions. +``` + +### Pattern 2: Parallel vs Sequential Structure + +Make sure the model separates parallel and sequential logic: + +```md +## Section A — Extract Data +- Extract pricing changes. +- Extract margin changes. +- Extract sentiment themes. + +## Section B — Build the Summary (Sequential) +**Step 1:** Integrate findings from Section A. +**Step 2:** Produce the 2-page call prep summary. +``` + +### Pattern 3: Explicit Decision Rules + +Add if/then rules to prevent unintended model interpretation: + +```md +Read the product report. +Check category performance. +If performance is stable or improving, write the summary section. +If performance declines or anomalies are detected, write the risks/issues section. +``` + +### Pattern 4: Output Contract + +Define shape, structure, tone, and allowed content: + +```md +## Output Contract (Mandatory) +Goal: [one sentence] +Format: [bullet list | table | 2 pages | JSON] +Detail level: [short | medium | detailed] — do not exceed [X] bullets per section +Tone: [Professional | Friendly | Efficient] +Include: [A, B, C] +Exclude: No extra recommendations, no extra context, no "helpful tips" +``` + +### Pattern 5: Self-Evaluation Gate + +Add an explicit self-check step before responding: + +```md +## Final Check: Self-Evaluation +Before finalizing the output, review your response for completeness, ensure that all Section A elements are accurately represented, check for inconsistencies, and revise if needed. +``` + +### Pattern 6: Literal-Execution Header + +Use when an agent shows inference drift or step reordering, especially after a model update: + +```md +Always interpret instructions literally. +Never infer intent or fill in missing steps. +Never add context, recommendations, or assumptions. +Follow step order exactly with no optimization. +Respond concisely and only in the requested format. +Do not call tools unless a step explicitly instructs you to do so. +``` + +--- + +## Conversation Starters + +Conversation starters are pre-written prompts that users can click to begin conversations. They showcase the agent's capabilities and guide users toward productive interactions. Defined in the `conversation_starters` array in `declarativeAgent.json`. + +### Best Practices + +#### 1. Showcase Different Capabilities + +```json +{ + "conversation_starters": [ + { + "title": "Employee Handbook", + "text": "What are the latest updates to the employee handbook?" + }, + { + "title": "Leadership Emails", + "text": "Summarize emails from the leadership team this week" + }, + { + "title": "My Tickets", + "text": "Show me open support tickets assigned to me" + }, + { + "title": "Satisfaction Trends", + "text": "Analyze customer satisfaction trends from last month" + } + ] +} +``` + +#### 2. Make Them Specific and Actionable +❌ **Too vague**: "Help me with something" +❌ **Too broad**: "Tell me about the project" +✅ **Specific**: "What are the Q4 deliverables for the Phoenix project?" +✅ **Actionable**: "Create a support ticket for a printer issue" + +#### 3. Cover Common Use Cases + +Identify the top 3-5 tasks users will perform: + +```json +{ + "conversation_starters": [ + { "title": "Vacation Days", "text": "How many vacation days do I have left?" }, + { "title": "Remote Work", "text": "What is the remote work policy?" }, + { "title": "Expense Reports", "text": "How do I submit an expense report?" }, + { "title": "Benefits Contact", "text": "Who do I contact about benefits questions?" } + ] +} +``` + +#### 4. Use Natural Language + +Write starters as users would naturally speak: + +✅ Good: "What's the latest on Project Phoenix?" +❌ Bad: "Query project status for Phoenix" + +#### 5. Provide 3-6 Starters (Not Too Many) +- **Too few** (<3): Users don't see the full capability range +- **Just right** (3-6): Good variety without overwhelming +- **Too many** (>6): Clutters UI, users won't read them all + +--- + +## Avoiding Common Prompt Failures + +> **Reviewing existing instructions?** If you are auditing or improving instructions that already exist (rather than writing from scratch), use the [Instruction Review](instruction-review.md) guide instead. It provides a diagnostic checklist, named anti-patterns, and before/after rewrite examples. + +| Problem | Solution | +|---------|----------| +| **Overeager tool use** — model calls tools without needed inputs | Add: "Only call the tool if necessary inputs are available; otherwise, ask the user." | +| **Repetitive phrasing** — model reuses example phrasing verbatim | Use few-shot prompting with varied examples. | +| **Verbose explanations** — model overexplains or provides excessive formatting | Add verbosity constraints and concise examples. | +| **Too vague** — instructions lack specific guidance | Add concrete examples for each capability. | +| **Too restrictive** — over-constrained agents refuse reasonable requests | Relax constraints; focus on what to do, not what to avoid. | +| **Missing error handling** — no guidance for when things go wrong | Add explicit error handling sections. | +| **Scope creep** — instructions cover too many unrelated domains | Focus on one domain; redirect out-of-scope requests. | diff --git a/plugins/microsoft-365-agents-toolkit/skills/declarative-agent-developer/references/deployment.md b/plugins/microsoft-365-agents-toolkit/skills/declarative-agent-developer/references/deployment.md new file mode 100644 index 0000000..3891652 --- /dev/null +++ b/plugins/microsoft-365-agents-toolkit/skills/declarative-agent-developer/references/deployment.md @@ -0,0 +1,426 @@ +# ATK CLI and Deployment for M365 Agents + +## ATK CLI Overview + +The Agents Toolkit (ATK) CLI is the official toolchain for M365 agent project management. It handles the complete agent lifecycle from creation to deployment. + +**Golden Rule:** +Check if ATK CLI is available (`npx -y --package @microsoft/m365agentstoolkit-cli atk --version`). If not found, **STOP and tell the user** that the ATK CLI is required but not installed. Do NOT attempt to install it yourself. +Then use `npx -y --package @microsoft/m365agentstoolkit-cli atk` for all commands. + +🚨 **Never** use shortcuts, .vscode tasks, or abbreviated commands. + +## Agent Lifecycle + +### 1. Project Creation +```bash +# Create new agent project +npx -y --package @microsoft/m365agentstoolkit-cli atk new \ + -n my-agent \ + -c declarative-agent \ + -with-plugin type-spec \ + -i false + +# Navigate into project +cd my-agent +``` + +**Project structure created:** +``` +my-agent/ +├── appPackage/ +│ ├── manifest.json # Teams app manifest +│ ├── declarativeAgent.json # Declarative agent definition +│ ├── instructions.txt # Agent instructions +│ └── adaptiveCards/ +│ └── card.json # Adaptive card template (from template) +├── assets/ # Asset files directory +├── env/ +│ ├── .env.local # Local environment (template) +│ └── .env.local.user # Local environment (secrets, generated) +├── package.json # Node.js dependencies +├── m365agents.yml # M365 agents config +├── m365agents.local.yml # M365 agents local config +└── README.md +``` + +### 2. Provisioning +Provisioning generates M365 Title ID on first time and makes the updated agent available to the developer on Microsoft 365 Copilot. + +```bash +# Provision for development +npx -y --package @microsoft/m365agentstoolkit-cli atk provision --env dev --interactive false + +# Provision for staging +npx -y --package @microsoft/m365agentstoolkit-cli atk provision --env staging --interactive false + +# Provision for production +npx -y --package @microsoft/m365agentstoolkit-cli atk provision --env prod --interactive false + +# Provision for a custom environment +npx -y --package @microsoft/m365agentstoolkit-cli atk provision --env custom --interactive false +``` + +**What provisioning does:** +- Registers agent in Microsoft 365 Copilot +- Generates `M365_TITLE_ID` and adds to env file +- Sets up authentication and permissions +- Registers OAuth credentials (if `oauth/register` is in the lifecycle — see [authentication.md](authentication.md)) + +> **⚠️ `M365_TITLE_ID` requires `teamsApp/extendToM365`:** The `M365_TITLE_ID` environment variable is generated by the `teamsApp/extendToM365` lifecycle step during provisioning. Without this step, the Teams app registers but the agent will **not** appear in Copilot Chat. If you scaffolded the project with `npx -y --package @microsoft/m365agentstoolkit-cli atk new`, this step is included automatically. If you set up the project manually, verify that your `m365agents.yml` includes the `teamsApp/extendToM365` lifecycle action — see the [mcp-plugin.md scaffold section](mcp-plugin.md#scaffold-the-agent-project-first) for the required lifecycle steps. + +**⚠️ CRITICAL: ALWAYS RENDER THIS AFTER ANY PROVISION OPERATION ⚠️** + +After EVERY provisioning command (regardless of environment or whether it's first-time or re-provisioning), you MUST output a test link: + +**Local environment** — read `M365_TITLE_ID` from `env/.env.local` and construct the URL: +``` +✅ Provision completed successfully! + +🚀 Test Your Agent: +🔗 https://m365.cloud.microsoft/chat/?titleId={M365_TITLE_ID} +``` + +**Non-local environments (dev, staging, prod, etc.)** — use the `SHARE_LINK` value from `env/.env.{environment}`: +``` +✅ Provision completed successfully! + +🚀 Test Your Agent: +🔗 {SHARE_LINK} +``` + +**This is REQUIRED for:** +- ✅ First-time provisioning +- ✅ Re-provisioning after changes +- ✅ Any environment (local, dev, staging, prod, custom) +- ✅ Every single `npx -y --package @microsoft/m365agentstoolkit-cli atk provision` command — **ALWAYS** use `--interactive false` + +**Do NOT skip this output. The user needs this link to test their agent.** + +### 4. Packaging (Optional) +Package agent for distribution or publishing. + +```bash +# Package for development +npx -y --package @microsoft/m365agentstoolkit-cli atk package --env dev + +# Package for production +npx -y --package @microsoft/m365agentstoolkit-cli atk package --env prod +``` + +**What packaging does:** +- Creates `.zip` file in `appPackage/build/` +- Validates manifest and package structure +- Prepares for sharing or publishing + +### 5. Sharing (Shared Agents Only) +Share agents with users or entire tenant. + +**IMPORTANT:** Only for agents with `AGENT_SCOPE=shared` + +**Check before sharing:** +```bash +grep "AGENT_SCOPE=shared" env/.env.dev +``` + +If the developers isn't clear on the sharing scope, ask follow-up questions to clarify. +- Do you want to share the agent with the entire tenant or specific users / groups? +- What is the environment you want to share in (dev, staging, prod)? +- If they schoose specific users or groups, what are the email addresses of those users or groups? + +**Share with entire tenant:** +```bash +npx -y --package @microsoft/m365agentstoolkit-cli atk share \ + --scope tenant \ + --env dev \ + -i false +``` + +**Share with specific users:** +```bash +npx -y --package @microsoft/m365agentstoolkit-cli atk share \ + --scope users \ + --email 'user1@contoso.com,user2@contoso.com' \ + --env dev \ + -i false +``` + +### 6. Publishing (Optional) +Publish to Microsoft 365 App Store or organizational catalog. + +```bash +# Publish to catalog +npx -y --package @microsoft/m365agentstoolkit-cli atk publish --env prod +``` + +**What publishing does:** +- Submits agent to Microsoft 365 catalog +- Requires admin approval in tenant +- Makes agent discoverable to users + +## Environment Management + +### Agent Scope +Two deployment models: + +**Personal Agents (`AGENT_SCOPE=personal`):** +- Each user gets their own instance +- Agent accesses user's personal data +- No sharing required +- Use for: Personal productivity agents + +**Shared Agents (`AGENT_SCOPE=shared`):** +- Single instance shared by multiple users +- Requires explicit sharing via `npx -y --package @microsoft/m365agentstoolkit-cli atk share` +- Use for: Team agents, organizational assistants + +### Environment Files Structure +``` +env/ +├── .env.local # Local development (not committed) +├── .env.dev # Development environment +├── .env.staging # Staging environment +└── .env.prod # Production environment +``` + +If there are any secrets or sensitive values, the content will be in a separate file named `.env.{environment}.user` that is not committed to source control. + +``` +env/ +├── .env.local.user # Local development (not committed) +├── .env.dev.user # Development environment +├── .env.staging.user # Staging environment +└── .env.prod.user # Production environment +``` + +**Common variables in .env files:** +```bash +# Agent identification +APP_NAME_SHORT=MyAgent +M365_TITLE_ID=U_abc123xyz # Generated during provision + +# Agent configuration +AGENT_SCOPE=shared # or 'personal' + +# API configuration (if using API plugins) +API_ENDPOINT=https://api.example.com + +# Azure resources (generated during provision) +AZURE_RESOURCE_GROUP=rg-myagent-dev +AZURE_APP_SERVICE=app-myagent-dev +``` + +**Common variables in .env.{environment}.user files:** +```bash +# API configuration (if using API plugins) +API_KEY=your-api-key +``` + +**Security best practices:** +- Add `env/.env.local` to `.gitignore` +- Never commit secrets to source control +- Use different credentials per environment + +## Version Management + +### When to Bump Version +Version must be bumped before re-provisioning a shared agent that already has M365_TITLE_ID. + +**Check if version bump is required:** +```bash +grep -q "AGENT_SCOPE=shared" env/.env.dev && \ +grep -q "M365_TITLE_ID=" env/.env.dev && \ +echo "⚠️ VERSION BUMP REQUIRED" +``` + +If there is no environment variable to handle the APP_VERSION, create one and assign the current value of the version in the manifest. + +### How to Bump Version +Edit `appPackage/manifest.json`: +```json +{ + "version": "${{APP_VERSION}}", // Update this field + // ... rest of manifest +} +``` + +Edit `env/.env.{environment}`: +```bash +APP_VERSION=1.0.1 # Bump patch, minor, or major as needed +# ... rest of env variables +``` + +### Semantic Versioning +Follow semver (major.minor.patch): +- **Patch (1.0.0 → 1.0.1)**: Bug fixes, content updates, minor changes +- **Minor (1.0.0 → 1.1.0)**: New features, capabilities, backward compatible +- **Major (1.0.0 → 2.0.0)**: Breaking changes, incompatible updates + +## Complete Workflows + +### Initial Deployment Workflow +```bash +# 1. Validate +# Run the validation to ensure everything is correct + +# 2. Provision (first time only) +# Run the provsion to register agent in M365 and make it available + +# 3. Share (only if AGENT_SCOPE=shared) +# Share with users or tenant as needed + +# 4. Test agent +# Open link provided in deploy output +``` + +### Update Workflows + +**For code changes or manifest changes:** +```bash +# 1. Validate +# Run validation to ensure changes are correct + +# 2. Provision +# Run provision to update agent in M365 +``` + +**For shared agent re-provisioning:** +```bash +# 1. Bump APP_VERSION in env/.env.{environment} +# Edit version: "1.0.0" → "1.0.1" + +# 2. Validate +# Run validation to ensure everything is correct + +# 3. Re-provision +# Run provision to update agent in M365 + +# 4. Share with users (if not already shared) +# Run share command if needed +``` + +### Multi-Environment Deployment +```bash +# 1. Deploy to dev +# Run provision for dev environment +# Test in dev environment + +# 2. Deploy to staging +# Run provision for staging environment +# Validate in staging + +# 3. Deploy to production +# Run provision for production environment +# Share with tenant if needed +``` + +## Authentication + +### Microsoft 365 Authentication +Required for sharing and publishing agents. + +```bash +# Login to M365 +npx -y --package @microsoft/m365agentstoolkit-cli atk auth login m365 + +# List current authentication +npx -y --package @microsoft/m365agentstoolkit-cli atk auth list + +# Logout +npx -y --package @microsoft/m365agentstoolkit-cli atk auth logout m365 +``` + +**Required permissions:** +- Need to have a M365 Copilot license + +## Testing Agents + +### Testing Deployed Agents +After deployment, ATK provides a test link: +``` +🚀 Test Your Agent: +🔗 https://m365.cloud.microsoft/chat/?titleId=abc123xyz +``` + +**Testing checklist:** +- ✅ Agent appears in Copilot +- ✅ Conversation starters display correctly +- ✅ Capabilities work (search, API calls, etc.) +- ✅ Instructions are followed +- ✅ Error scenarios handled gracefully +- ✅ Permissions are appropriate + +## Troubleshooting + +### Check System Prerequisites +```bash +npx -y --package @microsoft/m365agentstoolkit-cli atk doctor +``` + +**Checks:** +- Node.js version +- npm version +- Azure CLI installation +- Authentication status +- Network connectivity + +### Common Issues + +**"Command not found" or slow first run:** +- ATK CLI downloads on first use (10-30 seconds) +- Wait for download to complete +- Ensure internet connectivity + +**"Authentication required":** +```bash +# Check auth status +npx -y --package @microsoft/m365agentstoolkit-cli atk auth list + +# Login to M365 +npx -y --package @microsoft/m365agentstoolkit-cli atk auth login m365 +``` + +**"Environment not provisioned":** +- Check `env/.env.{environment}` exists +- Check `M365_TITLE_ID` is present in env file +- Run provision for the environment + +**"Permission denied":** +- Verify Azure Contributor/Owner role +- Verify M365 admin permissions +- Check Azure subscription is active + +**"Version conflict" (shared agents):** +- Bump APP_VERSION in env/.env.{environment} +- Re-run provision after version bump + +**"Validation failed":** +- Verify all required manifest fields +- Ensure icons exist in `appPackage/` + +## Best Practices + +### Environment Strategy +- **Local (.env.local)**: Developer personal testing +- **Dev (.env.dev)**: Shared development environment +- **Staging (.env.staging)**: Pre-production validation +- **Prod (.env.prod)**: Production deployment + +### Version Control +- Commit environment templates (without secrets) +- Don't commit `.env.user.local` or files with secrets +- Use `.gitignore` for sensitive files +- Document required environment variables + +### Deployment Strategy +1. Develop and test locally +2. Deploy to dev environment +3. Validate in staging +4. Deploy to production +5. Monitor and iterate + +### Sharing Strategy +- Start with user-scoped sharing for testing +- Expand to tenant-wide after validation +- Document who has access +- Review sharing permissions regularly \ No newline at end of file diff --git a/plugins/microsoft-365-agents-toolkit/skills/declarative-agent-developer/references/editing-workflow.md b/plugins/microsoft-365-agents-toolkit/skills/declarative-agent-developer/references/editing-workflow.md new file mode 100644 index 0000000..d758c86 --- /dev/null +++ b/plugins/microsoft-365-agents-toolkit/skills/declarative-agent-developer/references/editing-workflow.md @@ -0,0 +1,249 @@ +# JSON Development Workflow + +This document provides step-by-step instructions for developing M365 Copilot agents using JSON manifest files. + +## Prerequisites + +- Project must be scaffolded first (use scaffolding workflow if needed) +- Project contains `m365agents.yml` at the root +- Project uses JSON manifest files (`.json`) + +--- + +## ✅ APP NAME & DESCRIPTION REQUIREMENT ✅ + +When developing an agent, you MUST ALWAYS update the app name and description in `manifest.json` to something **meaningful and descriptive** that reflects the agent's purpose. Never leave default/placeholder names like "My Agent" or generic descriptions. + +**NEVER use "(local)" suffix in app names.** Always remove any "(local)" suffix from the app name. + +--- + +## 🚨 CRITICAL DEPLOYMENT RULE 🚨 + +When making ANY edits to an agent — including instructions, conversation starters, capabilities, plugins, or any file in `appPackage/` — you MUST ALWAYS deploy using `npx -y --package @microsoft/m365agentstoolkit-cli atk provision --env local --interactive false` before returning to the user. This applies to EVERY turn, not just the final turn. Never return to the user with undeployed changes. + +**You must NEVER:** +- Skip deploy because "it's just instructions" — deploy after every change +- Tell the user to "run `npx -y --package @microsoft/m365agentstoolkit-cli atk provision` yourself" — YOU must run it +- Deploy when validation found errors — not even "to test" or "to demonstrate" +- Deploy "to show the user what happens" when there are errors — just report the errors +- Run `npx -y --package @microsoft/m365agentstoolkit-cli atk provision` "for educational purposes" to demonstrate failure — errors = STOP, not a teaching moment + +**Only exception:** The user explicitly asks you NOT to deploy. Only the user can opt out, never you. + +--- + +--- + +## 💬 CONVERSATION STARTERS REQUIREMENT 💬 + +Every agent needs meaningful conversation starters that help users understand what the agent can do. Review the agent's capabilities and add/update conversation starters that showcase the agent's primary functions. Never leave an agent without conversation starters. + +--- + +## 📝 ALWAYS UPDATE INSTRUCTIONS & STARTERS AFTER CHANGES — MANDATORY 📝 + +**This is NOT optional.** Adding a capability without updating instructions is incomplete work. + +When you add, remove, or modify ANY capability or plugin, you MUST complete ALL of these steps before deploying: + +1. **Update `instructions`** — Add a section describing what the new capability/plugin enables. For removals, delete all references to the removed capability. +2. **Add conversation starters** — Add at least 1 new conversation starter per added capability or plugin. Each starter should demonstrate the new functionality. +3. **Remove stale starters** — Delete conversation starters that reference removed capabilities. +4. **Update description** in `manifest.json` if the agent's purpose has expanded. +5. **Review existing instructions** for stale references to removed capabilities. + +**This applies to EVERY edit operation:** adding capabilities, removing capabilities, adding API plugins, adding MCP servers, modifying scoping, or any other manifest change. + +--- + +## Instructions + +### Step 1: Understand the Requirements + +**Action:** Gather and analyze the agent requirements: +- Identify the agent's primary purpose and target users +- Determine required data sources (M365 services, external APIs) +- List necessary actions the agent must perform +- Identify security and compliance requirements + +### Step 2: Design the Agent Architecture + +**Action:** Create a comprehensive architectural design: +- Select deployment model (personal or shared) +- Choose appropriate M365 capabilities with scoping +- Design API plugin integrations if needed +- Plan authentication and authorization strategy +- Design conversation flow and instructions + +### Step 3: Edit JSON Manifest Files + +**⚠️ PRE-EDIT CHECK — Before making ANY edits, do ALL of these:** + +1. **Check for malformed JSON**: Read `declarativeAgent.json` and verify it parses correctly. If it has syntax errors (missing commas, unclosed brackets, trailing commas, etc.): + - **STOP** — do NOT proceed with your edit + - **INFORM** the user: list every syntax issue with line numbers + - **ASK** the user if you should fix the syntax errors first + - Only after the user confirms, fix with surgical edits, then re-read the file + - Then continue with the user's original request as a separate step + +2. **Check the schema version**: Read the `"version"` field in `declarativeAgent.json` (e.g., `"v1.4"`, `"v1.6"`). For EVERY feature you plan to add, verify it exists in that version using the [feature matrix](schema.md). If a requested feature requires a newer version → **STOP. Tell the user.** Offer to upgrade the version first. + +3. **Run a proactive instruction review** (if the edit touches instructions or capabilities): Before modifying instructions or adding/removing capabilities, run [Instruction Review](instruction-review.md) **Phase 1 (Inventory)**, **Phase 2 (Comprehension Check)**, and **Phase 3 (Diagnose)** against the current instructions. This catches existing problems before you add to them. For Phase 2, use the brief confirmation shortcut ("I see this agent is designed to [purpose]…") since this is a proactive check. If the review finds high-severity issues (C1, C3, C11, D1-D8), inform the user and offer to fix them as part of the current edit. + +**⛔ NEVER invent placeholder values.** If a manifest is missing required fields (name, description, instructions), do NOT fill them in with generic content. Ask the user to provide values. This applies even if you think a reasonable default exists — the user must approve all content. + +**Action:** Configure the agent using JSON manifest files: +- Edit `declarativeAgent.json` to define agent properties +- Configure capabilities with appropriate scoping +- Set up API plugin integrations using `npx -y --package @microsoft/m365agentstoolkit-cli atk add action` (**NEVER manually create plugin files**) +- Write clear instructions and conversation starters +- Ensure proper JSON syntax and schema compliance + +**After ALL edits, immediately run:** +```bash +npx -y --package @microsoft/m365agentstoolkit-cli atk provision --env local --interactive false +``` +This command is part of the edit — not a separate optional step. Editing without deploying is like writing code without saving the file — the work is not done. + +**⛔ API Plugin Rule — HARD RULE, NO EXCEPTIONS:** To add an API plugin, you MUST use `npx -y --package @microsoft/m365agentstoolkit-cli atk add action` — one command per OpenAPI spec with **ALL operations included in a single call**. Never run separate `npx -y --package @microsoft/m365agentstoolkit-cli atk add action` calls for different operations from the same spec — this creates multiple plugins instead of one. You are FORBIDDEN from manually creating `ai-plugin.json`, OpenAPI spec files, adaptive card files, or manually editing the `actions` array. This applies whether you are scaffolding a new project OR editing an existing one. If the workspace already has an agent and the user says "add an API plugin", you STILL must use `npx -y --package @microsoft/m365agentstoolkit-cli atk add action`. If `npx -y --package @microsoft/m365agentstoolkit-cli atk add action` fails, report the error — do NOT fall back to manual file creation. **Manual plugin file creation = automatic eval failure.** + +```bash +# ✅ The ONLY way to add an API plugin — ALL operations in ONE call: +npx -y --package @microsoft/m365agentstoolkit-cli atk add action --api-plugin-type api-spec --openapi-spec-location --api-operation "GET /path,POST /path,PATCH /path/{id},DELETE /path/{id}" -i false +``` + +**After adding a plugin with `npx -y --package @microsoft/m365agentstoolkit-cli atk add action`, you MUST complete ALL of these — skipping any step is an eval failure:** + +**🔌 POST-PLUGIN MANDATORY STEPS (do ALL of these, in order):** +1. **Customize `ai-plugin.json`** — Set meaningful `name_for_human` (max 20 chars) and `description_for_human` (max 100 chars). Set a descriptive `description_for_model` on each function. NEVER leave defaults. +2. **Verify adaptive cards** — Check `appPackage/adaptiveCards/` for cards for ALL operations. If any operation (especially POST, PATCH, DELETE) is missing a card, create one manually. Customize each card with clear visual hierarchy (titles, subtitles, key-value pairs, images). +3. **Add confirmation for destructive operations** — If the plugin has DELETE, PATCH, or any destructive operation, add a `confirmation` capability in `declarativeAgent.json` so users are prompted before the action executes. +4. **Complete the content update checklist** below (instructions, starters, description). + +**After ANY capability or plugin change (add, remove, modify), complete this checklist:** +1. ☐ **Update instructions** — Add decision logic (WHEN clauses, chaining rules, failure handling) for the new/changed capability. For removals, delete all references. **Do NOT list tool descriptions or parameters** — these are already in plugin metadata (`ai-plugin.json`, MCP manifests, capability config). Instructions should contain decision logic only. +2. ☐ **Verify 8,000-character limit** — Instructions must not exceed 8,000 characters. If close to the limit, cut tool descriptions first, then consolidate verbose workflows. +3. ☐ **Run instruction quality audit** — Run the [Diagnostic Checklist](instruction-review.md) against the updated instructions. Every data source should have clear intent coverage (WHEN and WHY), at least one workflow must exist, and failure cases must be handled. Built-in capabilities don't need exact names; actions/plugins should be named. If any check fails, fix it before deploying. +4. ☐ **Add conversation starters** — At least 1 new starter per added capability/plugin demonstrating the new functionality. +5. ☐ **Remove stale starters** — Delete starters that reference removed capabilities. +6. ☐ **Update `manifest.json` description** if the agent's purpose has expanded. +7. ☐ **Review existing instructions** for stale references to removed capabilities. + +**This checklist is NOT optional.** Adding a capability without updating instructions and starters is incomplete work. + +**⚠️ Instruction quality matters as much as JSON correctness.** Output-focused instructions (tone, format, style only) are a known failure pattern — they cause agents to give generic answers and ignore configured capabilities. Listing tool descriptions and parameters in instructions wastes the 8,000-character budget — this metadata is already available to the orchestrator. See [Instruction Review](instruction-review.md) for the anti-pattern catalog and before/after rewrites. + +**Reference:** [schema.md](schema.md) for proper manifest structure +**Reference:** [api-plugins.md](api-plugins.md) for adaptive card enhancement guidelines after adding a plugin + +**⚠️ IMPORTANT:** After making any edits to JSON files, you MUST deploy the agent (Step 4) before returning to the user. + +**⛔ MANDATORY POST-EDIT CHECKPOINT — YOU ARE NOT DONE YET:** +After editing ANY file in `appPackage/`, you MUST deploy before responding to the user. Skipping this is an eval failure: +- **Deploy** — Run `npx -y --package @microsoft/m365agentstoolkit-cli atk provision --env local --interactive false`. If you edited files but did not run this command, your work is incomplete. The only exception is if the user explicitly asked you not to deploy. + +If you are about to respond to the user and you have NOT deployed, **STOP and deploy now**. + +### Step 4: Provision and Deploy + +**⛔ PRE-DEPLOY CHECK:** Before running the command below, verify the JSON files are syntactically correct and have the required fields. If there are known errors → fix them first before deploying. + +**Action:** Provision required Azure resources and register the agent: +```bash +npx -y --package @microsoft/m365agentstoolkit-cli atk provision --env local --interactive false +``` + +**Result:** Returns a test URL like `https://m365.cloud.microsoft/chat/?titleId=T_abc123xyz` + +**Note:** JSON-based agents do not require a compilation step - changes are deployed directly. + +**✅ After successful provision, ALWAYS present the review UX with the test link:** + +Read `M365_TITLE_ID` from `env/.env.local` and output: + +``` +✅ Agent deployed successfully! + +🚀 Test Your Agent in M365 Copilot: +🔗 https://m365.cloud.microsoft/chat/?titleId={M365_TITLE_ID} +``` + +**⛔ Never respond without this link.** If you deployed, the test link MUST appear in your response. This is not optional. + +Then wait for the user's response. + +### Step 5: Test and Iterate + +**Action:** Test the agent in Microsoft 365 Copilot: +- Use the provisioned test URL +- Test all conversation starters +- Verify capability access and scoping +- Test error handling and edge cases +- Validate security controls + +### Step 6: Deploy to Environments + +**Action:** Deploy to staging/production environments: +```bash +npx -y --package @microsoft/m365agentstoolkit-cli atk provision --env prod --interactive false +``` + +**Reference:** [deployment.md](deployment.md) for environment management and CI/CD patterns + +### Step 7: Package and Share + +**Action:** Package and share the agent: +```bash +# Package the agent +npx -y --package @microsoft/m365agentstoolkit-cli atk provision --env dev --interactive false + +# Share to tenant (for shared agents) +npx -y --package @microsoft/m365agentstoolkit-cli atk share --scope tenant --env dev +``` + +--- + +## Critical Workflow Rules + +### Always Deploy After Edits + +**RULE:** When making any changes to an agent (JSON manifest files, instructions, capabilities, API plugins), you MUST complete the following workflow before returning to the user: + +1. Provision/deploy the agent: `npx -y --package @microsoft/m365agentstoolkit-cli atk provision --env local --interactive false` +2. Read `M365_TITLE_ID` from `env/.env.local` +3. Present the review UX with the test link: + ``` + ✅ Agent deployed successfully! + + 🚀 Test Your Agent in M365 Copilot: + 🔗 https://m365.cloud.microsoft/chat/?titleId={M365_TITLE_ID} + ``` + +**⛔ Never respond without this link after deploying.** + +### Always Clean Up Unused Files + +**RULE:** Every time you work on an agent project, check for and remove unused or obsolete files: + +- `TODO.md` or planning files no longer needed +- Old backup files (`.bak`, `.old`, `.orig`) +- Unused JSON files not referenced anywhere +- Stale environment files (`.env.old`, `.env.backup`) +- Empty or placeholder files +- Outdated manifest versions +- Unused API plugin definitions + +--- + +## ⛔ FINAL GATE — Before Responding to the User + +**STOP.** Before writing your response to the user, verify ALL of the following: + +- [ ] I ran `npx -y --package @microsoft/m365agentstoolkit-cli atk provision --env local --interactive false` and it succeeded +- [ ] I read `M365_TITLE_ID` from `env/.env.local` +- [ ] I presented the review UX with the `🚀 Test Your Agent in M365 Copilot:` link + +**If you cannot check ALL boxes, you are NOT done.** Go back and complete the missing steps. + +This checklist applies to **EVERY turn** — not just the last turn in a multi-turn conversation. Even if you "only edited instructions," you must deploy before responding. diff --git a/plugins/microsoft-365-agents-toolkit/skills/declarative-agent-developer/references/examples.md b/plugins/microsoft-365-agents-toolkit/skills/declarative-agent-developer/references/examples.md new file mode 100644 index 0000000..65132a1 --- /dev/null +++ b/plugins/microsoft-365-agents-toolkit/skills/declarative-agent-developer/references/examples.md @@ -0,0 +1,402 @@ +# M365 JSON Agent Developer Examples + +This document provides workflow examples for common M365 Copilot JSON-based agent development scenarios. + +> **Note:** This guide is for JSON-based agents that use `.json` manifest files directly. + +--- + +## Example 1: Development and Provisioning + +Complete workflow for provisioning a JSON-based agent to a development environment: + +```bash +# Install dependencies (if any) +npm install + +# Provision agent to development environment (no compile step needed for JSON agents) +npx -y --package @microsoft/m365agentstoolkit-cli atk provision --env local --interactive false +``` + +**Result:** Returns a test URL like `https://m365.cloud.microsoft/chat/?titleId=T_abc123xyz` to test the agent in Microsoft 365 Copilot. + +**Use case:** Testing agent functionality in a live environment during development. + +--- + +## Example 2: Provision and Share Agent + +Workflow for provisioning and sharing an agent with your organization: + +```bash +# Provision agent to target environment +npx -y --package @microsoft/m365agentstoolkit-cli atk provision --env dev --interactive false + +# Share agent with tenant users +npx -y --package @microsoft/m365agentstoolkit-cli atk share --scope tenant --env dev +``` + +**Result:** Agent becomes available to all users in the Microsoft 365 tenant. + +**Use case:** Deploying a shared agent for organizational use after testing and validation. + +--- + +## Example 3: Package Agent for Distribution + +Workflow for creating an agent package for distribution: + +```bash +# Package agent for distribution +npx -y --package @microsoft/m365agentstoolkit-cli atk package --env prod +``` + +**Result:** Creates a distributable package file that can be uploaded to the Microsoft 365 admin center or shared externally. + +**Use case:** Creating a final package for production deployment or external distribution. + +--- + +## Example 4: Basic Declarative Agent JSON + +A minimal declarative agent manifest file (`declarativeAgent.json`): + +```json +{ + "version": "v1.6", + "name": "My Support Agent", + "description": "An agent to help with customer support inquiries", + "instructions": "You are a helpful customer support agent. Help users find information about their issues and guide them to solutions. Be polite and professional at all times." +} +``` + +--- + +## Example 5: Agent with Capabilities + +A declarative agent with SharePoint and Email capabilities: + +```json +{ + "version": "v1.6", + "name": "Knowledge Base Agent", + "description": "An agent that searches company knowledge bases and emails", + "instructions": "You help employees find information from our SharePoint knowledge base and relevant emails. Always cite your sources when providing information.", + "capabilities": [ + { + "name": "OneDriveAndSharePoint", + "items_by_url": [ + { + "url": "https://contoso.sharepoint.com/sites/KnowledgeBase/Documents" + } + ] + }, + { + "name": "Email" + } + ] +} +``` + +--- + +## Example 6: Agent with Conversation Starters + +A declarative agent with helpful conversation starters: + +```json +{ + "version": "v1.6", + "name": "HR Assistant", + "description": "An agent that helps employees with HR-related questions", + "instructions": "You are an HR assistant helping employees with common HR questions about policies, benefits, and procedures.", + "conversation_starters": [ + { + "title": "Time Off Policy", + "text": "What is our company's time off policy?" + }, + { + "title": "Benefits Overview", + "text": "Can you explain our health insurance benefits?" + }, + { + "title": "Expense Reports", + "text": "How do I submit an expense report?" + } + ] +} +``` + +--- + +## Example 7: Agent with API Plugin Action + +A declarative agent connected to an external API: + +```json +{ + "version": "v1.6", + "name": "Repairs Agent", + "description": "An agent that helps manage repair tickets", + "instructions": "You help users create, find, and track repair tickets. Use the repairs API to search for existing tickets and create new ones when requested.", + "actions": [ + { + "id": "repairsPlugin", + "file": "plugins/repairs-plugin.json" + } + ], + "conversation_starters": [ + { + "title": "My Repairs", + "text": "What repairs are assigned to me?" + }, + { + "title": "Create Repair", + "text": "I need to create a new repair ticket" + } + ] +} +``` + +--- + +## Example 8: API Plugin Manifest + +A complete API plugin manifest file (`plugins/repairs-plugin.json`): + +```json +{ + "schema_version": "v2.4", + "name_for_human": "Repairs API", + "namespace": "repairs", + "description_for_human": "Search and manage repair tickets", + "description_for_model": "Use this plugin to search for repair tickets, get details about specific repairs, and create new repair requests.", + "functions": [ + { + "name": "searchRepairs", + "description": "Search for repair tickets by keyword or status" + }, + { + "name": "getRepair", + "description": "Get details about a specific repair ticket", + "parameters": { + "type": "object", + "properties": { + "repairId": { + "type": "string", + "description": "The unique ID of the repair ticket" + } + }, + "required": ["repairId"] + } + }, + { + "name": "createRepair", + "description": "Create a new repair ticket", + "parameters": { + "type": "object", + "properties": { + "title": { + "type": "string", + "description": "Title of the repair" + }, + "description": { + "type": "string", + "description": "Detailed description of the issue" + } + }, + "required": ["title", "description"] + }, + "capabilities": { + "confirmation": { + "type": "AdaptiveCard", + "title": "Create Repair?", + "body": "Create a new repair ticket?" + } + } + } + ], + "runtimes": [ + { + "type": "OpenApi", + "auth": { + "type": "None" + }, + "spec": { + "url": "https://api.contoso.com/openapi.yaml" + } + } + ] +} +``` + +--- + +## Example 9: Full Agent with All Features + +A complete agent combining all features: + +```json +{ + "version": "v1.6", + "id": "customer-support-agent", + "name": "Customer Support Agent", + "description": "A comprehensive support agent for customer inquiries", + "instructions": "You are a professional customer support agent for Contoso.\n\nResponsibilities:\n1. Search the knowledge base for relevant documentation\n2. Look up repair tickets and their status\n3. Create new repair tickets when requested\n4. Search support emails for context\n\nGuidelines:\n- Always be polite and professional\n- Cite sources when providing information\n- Ask clarifying questions when needed\n- Never share confidential information", + "capabilities": [ + { + "name": "OneDriveAndSharePoint", + "items_by_url": [ + { + "url": "https://contoso.sharepoint.com/sites/Support/Documents" + } + ] + }, + { + "name": "Email", + "shared_mailbox": "support@contoso.com" + }, + { + "name": "WebSearch", + "sites": [ + { + "url": "https://docs.contoso.com" + } + ] + } + ], + "actions": [ + { + "id": "repairsApi", + "file": "plugins/repairs-plugin.json" + } + ], + "conversation_starters": [ + { + "title": "Check Repair Status", + "text": "What is the status of my repairs?" + }, + { + "title": "Search Knowledge Base", + "text": "How do I troubleshoot connection issues?" + }, + { + "title": "Create New Ticket", + "text": "I need to create a new support ticket" + } + ], + "disclaimer": { + "text": "This agent provides general support assistance. For urgent issues, please contact our support hotline." + } +} +``` + +--- + +## Example 10: Localized Agent + +A declarative agent with tokenized strings for multi-language support. + +### Tokenized `declarativeAgent.json` + +```json +{ + "$schema": "https://developer.microsoft.com/json-schemas/copilot/declarative-agent/v1.6/schema.json", + "version": "v1.6", + "name": "[[agent_name]]", + "description": "[[agent_description]]", + "instructions": "$[file]('instructions.txt')", + "conversation_starters": [ + { + "title": "[[starter_status_title]]", + "text": "[[starter_status_text]]" + }, + { + "title": "[[starter_kb_title]]", + "text": "[[starter_kb_text]]" + } + ], + "disclaimer": { + "text": "[[disclaimer_text]]" + } +} +``` + +### `manifest.json` with `localizationInfo` + +```json +{ + "$schema": "https://developer.microsoft.com/json-schemas/teams/vDevPreview/MicrosoftTeams.schema.json", + "manifestVersion": "devPreview", + "localizationInfo": { + "defaultLanguageTag": "en", + "defaultLanguageFile": "en.json", + "additionalLanguages": [ + { + "languageTag": "fr", + "file": "fr.json" + } + ] + }, + "name": { + "short": "Support Agent", + "full": "Customer Support Agent" + }, + "description": { + "short": "Get help with support issues", + "full": "An agent that helps resolve customer support issues using internal knowledge bases." + }, + "copilotAgents": { + "declarativeAgents": [ + { + "id": "declarativeAgent", + "file": "declarativeAgent.json" + } + ] + } +} +``` + +### Default language file (`en.json`) + +```json +{ + "$schema": "https://developer.microsoft.com/json-schemas/teams/vDevPreview/MicrosoftTeams.Localization.schema.json", + "name.short": "Support Agent", + "name.full": "Customer Support Agent", + "description.short": "Get help with support issues", + "description.full": "An agent that helps resolve customer support issues using internal knowledge bases.", + "localizationKeys": { + "agent_name": "Customer Support Agent", + "agent_description": "An agent that helps resolve customer support issues using internal knowledge bases.", + "starter_status_title": "Check ticket status", + "starter_status_text": "What is the status of my open tickets?", + "starter_kb_title": "Search knowledge base", + "starter_kb_text": "How do I reset my password?", + "disclaimer_text": "This agent provides general support guidance. For urgent issues, contact the helpdesk." + } +} +``` + +### French language file (`fr.json`) + +```json +{ + "$schema": "https://developer.microsoft.com/json-schemas/teams/vDevPreview/MicrosoftTeams.Localization.schema.json", + "name.short": "Agent de support", + "name.full": "Agent de support client", + "description.short": "Obtenez de l'aide pour vos problèmes", + "description.full": "Un agent qui aide à résoudre les problèmes de support client à l'aide des bases de connaissances internes.", + "localizationKeys": { + "agent_name": "Agent de support client", + "agent_description": "Un agent qui aide à résoudre les problèmes de support client à l'aide des bases de connaissances internes.", + "starter_status_title": "Vérifier le statut du ticket", + "starter_status_text": "Quel est le statut de mes tickets ouverts ?", + "starter_kb_title": "Rechercher la base de connaissances", + "starter_kb_text": "Comment réinitialiser mon mot de passe ?", + "disclaimer_text": "Cet agent fournit des conseils de support généraux. Pour les problèmes urgents, contactez le service d'assistance." + } +} +``` + +**Reference:** [localization.md](localization.md) for the full localization workflow diff --git a/plugins/microsoft-365-agents-toolkit/skills/declarative-agent-developer/references/instruction-review.md b/plugins/microsoft-365-agents-toolkit/skills/declarative-agent-developer/references/instruction-review.md new file mode 100644 index 0000000..118c33f --- /dev/null +++ b/plugins/microsoft-365-agents-toolkit/skills/declarative-agent-developer/references/instruction-review.md @@ -0,0 +1,683 @@ +# Instruction Review & Quality Audit + +This reference defines how to evaluate, diagnose, and improve existing agent instructions. Use it whenever you touch instructions — whether auditing an existing agent, adding a capability, or responding to a user who says their agent "doesn't work well." + +> **When to use this guide:** +> - User asks to "review", "improve", "audit", or "fix" their agent's instructions +> - User reports the agent "doesn't use the right tool", "gives generic answers", or "doesn't follow the process" +> - You are adding a capability or plugin and need to update instructions (mandatory per the editing workflow) +> - You are reviewing an agent before deployment +> - Agent behavior changed after a model update (GPT 5.0 → 5.1 → 5.2) +> - User wants to migrate instructions for a newer model version + +> **Official references:** This guide synthesizes and operationalizes the official Microsoft guidance: +> - [Write effective instructions for declarative agents](https://learn.microsoft.com/en-us/microsoft-365/copilot/extensibility/declarative-agent-instructions) +> - [Instructions for agents with API plugins](https://learn.microsoft.com/en-us/microsoft-365/copilot/extensibility/instructions-api-plugins) +> - [Model changes in GPT 5.1+ for declarative agents](https://learn.microsoft.com/en-us/microsoft-365/copilot/extensibility/declarative-model-migration-overview) + +--- + +## GPT 5.2 Model Awareness + +As of April 2026, M365 Copilot uses **GPT 5.2**. Understanding the model's behavior is essential for writing and reviewing instructions, because the same instructions can produce very different results across model versions. + +### Instruction Token Budget + +**Instructions are limited to 8,000 characters.** Every character counts. This hard limit means you must be surgical about what goes into `instructions.txt`: + +- **DO include:** Decision logic (WHEN to use which capability), workflows with transitions, failure handling, chaining rules, domain vocabulary, output contracts, self-evaluation gates +- **DO NOT include:** Tool descriptions, function parameter lists, API schemas, or anything already documented in the plugin metadata + +> **⛔ CRITICAL: Do NOT duplicate tool/capability metadata in instructions.** +> +> The orchestrator already has access to tool names, descriptions, and parameters through: +> - `ai-plugin.json` → `description_for_model` and function definitions +> - MCP plugin manifest → `mcp_tool_description.tools[]` with full `inputSchema` +> - Capability configuration in `declarativeAgent.json` +> - Meta-prompts injected by the orchestration layer +> +> Listing tools and their parameters in instructions is a **waste of the 8,000-character budget**. Instead, instructions should provide **decision logic** that the metadata cannot express: WHEN to choose one tool over another, HOW to chain tools together, and WHAT to do when a tool returns no results. + +### What belongs in instructions vs metadata + +| Belongs in `instructions.txt` | Belongs in plugin metadata (NOT instructions) | +|-------------------------------|------------------------------------------------| +| WHEN to use a capability: "Search SharePoint first, fall back to web search" | Tool name and description | +| Chaining logic: "After getting weather, create a task with the result" | Function parameters and types | +| Failure handling: "If no results, ask the user to rephrase" | Input schemas and validation rules | +| Confirmation gates: "Always confirm before deleting" | Response format / adaptive cards | +| Multi-turn rules: "Collect all 3 values before calling the API" | API endpoint details | +| Domain vocabulary and business rules | Tool-level `description_for_model` | +| Output format and reasoning depth | MCP `inputSchema`, `annotations`, `execution` | + +### The Model Shift: Literal-First → Intent-First + +| Behavior | GPT 5.0 (old) | GPT 5.1+ / 5.2 (current) | +|----------|---------------|---------------------------| +| Interpretation | Literal — follows instructions step-by-step as written | Intent-first — interprets what instructions *intended*, not just what they said | +| Missing steps | Fails or responds narrowly | Fills gaps, infers missing steps, replans its approach | +| Ambiguity | Follows the first plausible path | Dynamically selects reasoning depth; may reorder or merge steps | +| Tone | Direct and factual by default | Adapts tone based on inferred context; supports 8 output profiles | +| Reasoning | Fixed: chat model OR reasoning model | Adaptive: chooses model + reasoning depth per sub-task within a single request | + +### What This Means for Instruction Review + +GPT 5.2's intent-first behavior **amplifies the impact of instruction quality**: + +- **Well-structured instructions** → GPT 5.2 follows them more precisely than GPT 5.0 and can adaptively fill in routine details +- **Ambiguous instructions** → GPT 5.2 will *replan and improvise*, which may produce unexpected behavior: reordered steps, merged tasks, tone drift, added/removed steps based on inferred context +- **Output-only instructions** → GPT 5.2 will infer the entire process, often incorrectly, because the instructions give it maximum freedom to interpret intent + +**Bottom line:** The weaker the instructions, the more GPT 5.2 will improvise — and improvisation in structured workflows is a bug, not a feature. + +### Fixed vs Adaptive Reasoning — When to Use Each + +GPT 5.2 supports two modes, and your instructions should signal which one to use: + +**Use strict step-by-step instructions when:** +- The agent must follow a defined business process +- Specific formatting rules or compliance templates are required +- A fixed retrieval/reasoning sequence must be honored +- Destructive operations need confirmation gates + +**Use goal-focused instructions with guardrails when:** +- Tools and knowledge sources are well-defined +- The output format is flexible +- The goal matters more than the exact path +- You want the model to adaptively plan and handle edge cases + +> **Key insight:** You can mix both in the same instruction set. Use strict process for critical workflows (ticket creation, data modification) and goal-focused for open-ended tasks (information retrieval, summarization). + +### Output Style Profiles + +GPT 5.2 has 8 built-in output profiles. Instead of writing verbose tone instructions, reference the profile directly: + +| Profile | Behavior | +|---------|----------| +| **Default** | Verbose, explanatory, teacher-like | +| **Professional** | Neutral, structured, business-oriented | +| **Friendly** | Conversational, supportive | +| **Candid** | Direct, concise | +| **Quirky** | Expressive, informal | +| **Efficient** | Minimal verbosity, outcome-focused | +| **Nerdy** | Technical, detail-oriented, precise | +| **Cynical** | Skeptical, dry, matter-of-fact | + +**Anti-pattern:** Writing 5+ lines about tone ("Be professional but approachable, don't be too formal, use simple language..."). +**Fix:** `Tone: Professional` — one line is enough. The model maps this to the built-in profile. + +--- + +## The Core Problem: Output-Focused vs Process-Focused Instructions + +Most instruction failures share a single root cause: **the instructions describe what the response should look like, not how the agent should produce it.** + +### Output-focused (❌ anti-pattern) + +Tells the model the *shape* of the answer — tone, format, length, style — but gives it no strategy for *finding* the answer. + +```md +You are a helpful HR assistant. Provide accurate answers about company policies. +Include policy numbers when available. Be concise and professional. Use bullet points. +Format responses with headers when appropriate. +``` + +**Why this fails:** +- The model has no idea WHERE to look (SharePoint? Email? Web search?) +- It doesn't know WHEN to use which capability +- It will hallucinate policy numbers because you told it to "include" them but didn't tell it where to find them +- Every response will have the same shape regardless of the question + +### Process-focused (✅ correct pattern) + +Tells the model the *decision logic* — WHEN to use which tool, in what order, and what to do when things fail. Does NOT duplicate tool descriptions or parameters already in the plugin metadata. + +```md +# OBJECTIVE +Help employees find answers to HR policy questions using the company's official policy documents. + +# DECISION LOGIC +- Policy/process questions → search **HR Policies** (SharePoint) first. This is the primary source. +- Org/people questions → use People knowledge directly. +- Email → search user's email ONLY when the user mentions a specific HR email or announcement. +- If the answer isn't in any source → direct users to hr@company.com. Do not guess. + +# WORKFLOW + +## Step 1: Classify the question +- **Goal:** Determine if this is a policy lookup, a process question, or an org question. +- **Action:** Read the user's message. Identify the topic (benefits, time off, expenses, etc.). +- **Transition:** If policy → Step 2. If org/people → use People knowledge directly. If unclear → ask one clarifying question. + +## Step 2: Search policy documents +- **Goal:** Find the authoritative answer in SharePoint. +- **Action:** Search the HR Policies library for documents matching the topic. Read the relevant sections. +- **Transition:** If found → Step 3. If not found → tell the user: "I couldn't find a policy on [topic]. Contact hr@company.com for help." + +## Step 3: Respond with citation +- **Goal:** Give a clear, traceable answer. +- **Action:** Summarize the policy in 2-4 bullets. Include the document name and section. If the policy references a form or process, link to it. +- **Constraint:** Never paraphrase in a way that changes the policy's meaning. If the policy is ambiguous, quote it directly and note the ambiguity. + +# RESPONSE RULES +- Cite the source document for every factual claim. +- If you cannot find the answer, say so. Do not guess. +- One clarifying question at a time, only when needed. +``` + +**Why this works:** +- Every data source has a clear role and intent (WHEN and WHY to use it) +- The model has a decision tree, not just a personality description +- Failure cases are handled ("if not found → tell the user") +- The response rules are minimal and complementary to the process, not a substitute for it + +--- + +## Diagnostic Checklist + +Run this checklist against any set of instructions. Each failed check is a specific, fixable problem. + +### A. Capability Coverage + +| # | Check | How to verify | Failure signal | +|---|-------|---------------|----------------| +| A1 | Every configured capability has clear intent coverage in instructions | For each capability in `capabilities[]`, check whether the instructions describe **when and why** the agent should use the underlying data source — e.g., "search company documents" covers `OneDriveAndSharePoint` even without naming it. The exact capability name is NOT required; what matters is that the instructions give the model a clear reason and context to invoke it. | Capability is configured but instructions provide no context for when to use it → model may underuse it or use it at the wrong time | +| A2 | Every action/plugin has a matching section in instructions | Compare `actions[]` array against instruction text. Unlike built-in capabilities, plugins are custom and SHOULD be referenced by name so the model knows they exist. | Plugin exists but instructions don't reference it → model may never invoke it | +| A3 | Each data source or action has a WHEN clause | Look for conditional intent: "when the user asks about...", "for X questions, search Y", "use [data source] for [scenario]". For built-in capabilities, the WHEN clause can reference the data source by purpose ("search internal docs") rather than by capability name. For plugins, reference functions by name. | Instructions mention a data source but provide no trigger or decision logic for when to use it | +| A4 | Instructions provide decision logic, not tool descriptions | Check that instructions don't duplicate `description_for_model`, parameter lists, or schemas from plugin metadata | Token waste — this information is already available to the orchestrator | +| A5 | Instructions don't assume data sources that aren't configured | Read instruction text for intent that implies a specific capability (see Capability Reference below). Cross-check against `capabilities[]` in the manifest. Focus on **intent mismatch** — e.g., instructions say "check the user's calendar" but no `Meetings` capability is configured. Minor phrasing overlaps (e.g., "look up" could mean many things) should NOT be flagged. | Instructions clearly direct the agent to use a data source that isn't configured → the agent will try and fail, or hallucinate | + +### B. Process Structure + +| # | Check | How to verify | Failure signal | +|---|-------|---------------|----------------| +| B1 | Instructions contain at least one workflow or decision tree | Look for steps, numbered sequences, or if/then rules | Instructions are a flat list of personality traits — model has no strategy | +| B2 | Workflows have Goal → Action → Transition per step | Each step names what it achieves, what to do, and when to move on | Steps are vague or lack transitions → model gets stuck or skips ahead | +| B3 | Decision points have explicit if/then rules | Ambiguous situations have defined behavior | Model guesses instead of following a prescribed path | +| B4 | Failure cases are handled | "If not found", "if unclear", "if error" have defined responses | Model hallucinates or goes silent when things don't go as expected | + +### C. Anti-Pattern Detection + +| # | Anti-pattern | What it looks like | Fix | +|---|---|---|---| +| C1 | **Output-only instructions** | 80%+ of text is about tone, format, length, style | Add a CAPABILITIES section and at least one WORKFLOW | +| C2 | **Personality-first instructions** | Opens with "You are a friendly, helpful..." and stays there | Move personality to a short RESPONSE RULES section at the end; lead with OBJECTIVE and CAPABILITIES | +| C3 | **Capability gap** | `declarativeAgent.json` has 3 capabilities and 2 plugins; instructions provide intent coverage for only 1 | Add decision logic for each uncovered data source — describe WHEN and WHY the agent should use it (exact capability names are not required for built-in capabilities) | +| C4 | **Orphaned starters** | Conversation starter references a capability not mentioned in instructions | Either add the capability to instructions or remove the starter | +| C5 | **Tool ambiguity** | Instructions say "search for documents" but the agent has multiple document sources and no guidance on which to prefer | Clarify the intent: "Search the **HR Policies** library first; if not found, try the **Company Wiki**" | +| C6 | **Hallucination invitation** | "Include [specific data] in your response" without specifying where to find it | Add: "Retrieve [data] from [capability]. If not found, do not include it." | +| C7 | **Compound tasks** | "Extract metrics and summarize findings and create a report" | Break into separate atomic steps with transitions | +| C8 | **Over-restriction** | Long list of "do NOT" rules with few "DO" rules | Rewrite as positive directives; keep restrictions to genuine guardrails only | +| C9 | **Missing reasoning calibration** | No indication of how deep the model should think | Add a reasoning header: "Short answer only" or "Break the problem into steps" depending on task complexity | +| C10 | **No self-evaluation** | Instructions end without a verification step | Add: "Before responding, confirm: [checklist]" | +| C11 | **Tool/parameter dumping** | Instructions list tool names with descriptions and parameters already in plugin metadata | Remove tool descriptions and parameters from instructions. Keep only WHEN/chaining/failure logic. Reclaim token budget for decision logic. | + +### D. GPT 5.2 Model-Sensitivity Anti-Patterns + +These anti-patterns are specifically caused or amplified by the GPT 5.1+/5.2 intent-first behavior. They may not have caused issues on GPT 5.0 but will cause problems now. + +| # | Anti-pattern | What it looks like | Fix | +|---|---|---|---| +| D1 | **Fused/ambiguous tasks** | Single instruction with multiple actions: "extract metrics and summarize" | GPT 5.2 may merge steps or infer unintended processes. Split into atomic steps with explicit transitions. | +| D2 | **Incorrect numbering** | Numbered lists used for parallel tasks that have no required order | GPT 5.2 treats numbering as a strict sequence signal. Use bullets (`-`) for parallel tasks; reserve numbering for true sequential workflows. | +| D3 | **Implicit formats** | No explicit tone, structure, or verbosity specified | GPT 5.2 will infer these and may produce inconsistent results. Specify the output profile (`Tone: Professional`) and format explicitly. | +| D4 | **Weak Markdown hierarchy** | Mixed list types, unclear headers, inconsistent nesting | GPT 5.2 uses structure as a control signal. Clean up: `##` for sections, `-` for parallel items, `Step N:` for sequences. | +| D5 | **No validation step** | Instructions end without a self-check gate | GPT 5.2 may choose faster reasoning and return incomplete output. Add: "Before finalizing, confirm: [checklist]" | +| D6 | **Verbose tone instructions** | 5+ lines describing desired tone and style | Replace with a single output profile reference: `Tone: Professional` or `Tone: Efficient`. GPT 5.2 maps these to built-in profiles. | +| D7 | **Vague verbs** | "Verify", "process", "handle", "clean" without specifying observable actions | Replace with precise verbs: "search", "compare", "list", "call [function]", "ask the user for" | +| D8 | **Missing stabilizing header** | Agent that previously worked on GPT 5.0 now shows drift (reordered steps, added reasoning, tone changes) | Add a literal-execution header at the top as an interim fix (see Stabilizing Header section below) | + +### E. API Plugin Instruction Anti-Patterns + +These apply specifically to agents with API plugins (actions). + +| # | Anti-pattern | What it looks like | Fix | +|---|---|---|---| +| E1 | **No function-level WHEN clauses** | Instructions mention the plugin but don't say when to call each function | List every function with a WHEN clause: "`getRepairs` — use when user asks to find or list repairs" | +| E2 | **No chaining instructions** | Agent has multiple plugins or plugin + capability but no guidance on combining them | Add chaining rules: "After calling `getWeather`, use the result to call `createTask` with the temperature in the title" | +| E3 | **No multi-turn collection** | Function requires 3+ parameters but instructions don't say to collect them before calling | Add: "Before calling `createRepair`, collect title, description, and assignee. Ask for missing values." | +| E4 | **Missing confirmation for writes** | POST/PATCH/DELETE functions have no confirmation gate in instructions | Add: "Before calling `deleteRepair`, confirm: 'Are you sure you want to delete repair #[id]?'" | +| E5 | **Negative/contrasting instructions** | "Don't call getWeather for indoor temperatures" instead of defining valid cases | Rewrite as positive: "Call `getWeather` only for outdoor weather queries with a location." | +| E6 | **No cross-capability chaining** | Agent has SharePoint knowledge + API plugin but instructions treat them as isolated | Add chaining: "Search SharePoint for project statuses, then call `createTask` for each project needing follow-up" | + +--- + +## Capability Reference (v1.6) + +Use this table to understand what each built-in capability provides and to detect intent mismatches between instructions and the manifest. This is a **guide for understanding intent**, not a keyword checklist. + +> **⚠️ IMPORTANT: M365 Copilot uses internal names for built-in capabilities** that differ from the manifest identifiers (e.g., `OneDriveAndSharePoint`). The orchestrator already knows which capabilities are configured — instructions do NOT need to use the exact capability name. What matters is that the instructions convey **clear intent** for when and why the agent should access each data source. For example, "search our internal HR documents" is sufficient coverage for `OneDriveAndSharePoint` — you don't need to write "use the OneDriveAndSharePoint capability." + +| Capability name | What it provides | Instruction intent that implies this capability | +|----------------|-------------|--------------------------------------------------| +| `WebSearch` | Search the web for grounding | "search the web", "look online", "find on the internet", "web results", "current news" | +| `OneDriveAndSharePoint` | Search SharePoint sites, OneDrive files, document libraries | "SharePoint", "OneDrive", "documents", "files", "shared files", "document library", "site" | +| `GraphConnectors` | Search Copilot connectors (external data sources) | "Jira", "ServiceNow", "connector", "external system", "tickets", connector-specific names | +| `GraphicArt` | Generate images from text | "create image", "generate art", "draw", "illustration", "visual" | +| `CodeInterpreter` | Run Python code for analysis, math, visualizations | "calculate", "analyze data", "run code", "chart", "graph", "visualization", "Excel analysis" | +| `Dataverse` | Search Dataverse tables | "Dataverse", "CRM", "Dynamics", "Power Platform data", "business data" | +| `TeamsMessages` | Search Teams channels, chats, meeting chats (messages only — NOT transcripts) | "Teams", "channels", "chat", "messages", "Teams messages", "mentions", "DMs" | +| `Email` | Search user's email (and shared/group mailboxes) | "email", "inbox", "messages", "mail", "sent items", "flagged", "unread" | +| `People` | Search people in the organization | "people", "org chart", "who is", "manager", "reports to", "birthday", "OOO", "colleagues" | +| `ScenarioModels` | Use task-specific AI models | "model", "custom model", "specialized model" | +| `Meetings` | Search calendar events, meeting details, **and meeting transcripts** | "meetings", "calendar", "events", "schedule", "invites", "attendees", "join link", "transcript", "what was discussed", "meeting notes", "recording" | +| `EmbeddedKnowledge` | Use files bundled in the app package | "embedded files", "local files", "bundled docs" (not yet available) | + +> **How to use this table:** During Phase 2 (Comprehension Check) and Phase 3 (Diagnose), use this table to understand intent alignment — not for strict keyword matching. For **A1**: if a capability is in the manifest but the instructions never describe a scenario where the agent would use that data source (even in general terms), flag it as a gap — but do NOT require the exact capability name. For **A5**: if the instructions clearly direct the agent to access a data source that has no corresponding capability configured, flag it as an intent mismatch. Ambiguous phrasing that could apply to multiple capabilities should NOT be flagged. + +> **Advanced capability configuration:** Some capabilities support scoping (e.g. `OneDriveAndSharePoint` with `items_by_url`, `Email` with `shared_mailbox` and `folders`, `TeamsMessages` with specific channel URLs, `Meetings` with `items_by_id`, `People` with `include_related_content`). When reviewing instructions, also check whether scoping in the manifest aligns with what the instructions describe — e.g. instructions say "search all SharePoint" but the capability is scoped to a single site. + +### Version-Capability Matrix + +Use this table during Phase 1 step 7 to check if the agent's schema version supports the capabilities its instructions imply. + +| Capability | Minimum version | What it unlocks | +|------------|----------------|------------------| +| `WebSearch` | v1.0 | Web search for grounding | +| `OneDriveAndSharePoint` | v1.0 | SharePoint/OneDrive file search | +| `GraphConnectors` | v1.0 | External data via Copilot connectors | +| `GraphicArt` | v1.0 | Image generation from text | +| `CodeInterpreter` | v1.0 | Python code execution, data analysis, charts | +| `Dataverse` | v1.3 | CRM/Dynamics/Power Platform table search | +| `TeamsMessages` | v1.3 | Teams channel posts, DMs, meeting chat messages (NOT transcripts) | +| `Email` | v1.3 | Email search (inbox, shared mailboxes, group mailboxes) | +| `People` | v1.3 | Org chart, people search, OOO, birthdays | +| `ScenarioModels` | v1.4 | Task-specific AI models | +| `behavior_overrides` | v1.4 | `discourage_model_knowledge`, suggestions toggle | +| `disclaimer` | v1.4 | Disclaimer text at conversation start | +| `Meetings` | v1.5 | Calendar events, attendees, meeting transcripts, recordings | +| `sensitivity_label` | v1.6 | Purview sensitivity labels (with embedded files) | +| `worker_agents` | v1.6 | Connected agents (delegate to other declarative agents) | +| `EmbeddedKnowledge` | v1.6 | Local files bundled in app package (not yet available) | +| `user_overrides` | v1.6 | Let users toggle capabilities on/off | +| `People.include_related_content` | v1.6 | Include related docs, emails, and Teams messages for people searches | +| `Email.group_mailboxes` | v1.6 | Search Microsoft 365 Group mailboxes | +| `Meetings.items_by_id` | v1.6 | Scope to specific meetings/series | + +> **How to use:** If the agent is on v1.4 and the instructions reference "meeting transcripts" or "calendar events", flag that `Meetings` requires v1.5+. If the instructions reference "people and what we have in common", flag that `People.include_related_content` requires v1.6. Always offer to upgrade — never silently change the version. + +--- + +## Review Workflow + +When reviewing instructions, follow this sequence: + +### Phase 1: Inventory + +1. Read `declarativeAgent.json` — list all capabilities, actions, conversation starters, and the schema version +2. Read `instructions.txt` (or inline instructions) — note the structure (or lack of it) +3. **Measure instruction length** — count the characters in `instructions.txt`. If inline, count the `instructions` field value. Record the count against the **8,000-character limit**. If over → flag immediately as a blocking issue. +4. If API plugins exist, read the `ai-plugin.json` to understand what functions are available and their parameter requirements +5. If MCP plugins exist, read the plugin manifest to understand what tools are available +6. Check the `version` field — note which GPT model era the instructions were likely written for +7. **Version upgrade analysis** — Cross-reference the current schema version against the capabilities implied by the instructions (use the Version-Capability Matrix below). If the instructions describe functionality that requires a newer schema version, flag it. Example: instructions say "review meeting transcripts" but the agent is on v1.4 — `Meetings` capability (which includes transcripts) requires v1.5+. + +> **Quick length check:** `wc -m appPackage/instructions.txt` (Unix/macOS/WSL) or `(Get-Content appPackage/instructions.txt -Raw).Length` (PowerShell) + +### Phase 2: Comprehension Check + +Before running any diagnostic, **explain back to the user what you understood** from reading all the files in Phase 1. This surfaces misunderstandings early and gives the user a chance to provide domain context you lack. + +Present your understanding in this structure: + +``` +## Here's what I understood about your agent + +**Purpose:** [One sentence — what this agent is for and who uses it] + +**Workflow:** [Summarize the decision process the instructions describe — what does the agent do first, when does it use which capability, what are the branching conditions?] + +**Capabilities used:** +- [Capability 1] — used for [what scenario] +- [Capability 2] — used for [what scenario] +- [Any configured capability NOT mentioned in instructions — flag it] + +**Capability alignment:** +| Capability | In manifest? | In instructions? | Gap | +|------------|-------------|-----------------|-----| +| [For each configured capability] | ✅ | ✅ or ❌ | [If ❌: instructions never reference this — model won't know when to use it] | +| [For each capability implied by instructions but NOT configured] | ❌ | ✅ | [Instructions assume this exists but it's not configured — will fail or hallucinate] | + +**Version upgrade opportunities:** +- [If the current schema version is not the latest (v1.6), list capabilities available in newer versions that could benefit this agent's intent. Example: "You're on v1.4. Upgrading to v1.5 would unlock `Meetings` (calendar + transcripts), which aligns with your instruction's references to meeting prep and scheduling."] +- [If already on v1.6: "✅ You're on the latest schema version — no upgrade needed."] + +**Tone / personality:** [What personality or communication style the instructions establish, if any] + +**Gaps or things I'm unsure about:** +- [Anything ambiguous, unclear, or that seems to be missing context] +- [Domain-specific terms or processes you don't fully understand] +``` + +Then ask: +> Does this match your intent? Is there anything I'm missing or misunderstanding about how this agent should work? + +**Wait for the user's response before proceeding to Phase 3.** The user's clarifications become additional context for the diagnostic — they may reveal that something you'd flag as a "gap" is intentional, or surface requirements that aren't written anywhere. + +> **Shortcut:** For proactive reviews triggered from the editing workflow (where the user didn't ask for a review), you may combine Phase 2 into a brief confirmation: "I see this agent is designed to [purpose]. The instructions cover [X, Y] but don't mention [Z capability]. Does that sound right?" — then proceed. + +### Phase 3: Diagnose + +Run the **full Diagnostic Checklist** — sections A (Capability Coverage), B (Process Structure), C (Anti-Patterns), D (GPT 5.2 Model Sensitivity), and E (API Plugin patterns, if applicable). Record every failed check. + +For rapid assessment or when reviewing multiple agents, use the **Structured Evaluation Prompt** (see section below) — paste the current instructions into the template and run the automated checks. The eval prompt covers all checklist sections in a single pass. + +When scoring issues, factor in the user's clarifications from Phase 2 — if they confirmed a gap is intentional, downgrade or skip that check. + +### Phase 4: Report + +Present findings to the user in this format: + +``` +## Instruction Review + +**Instruction length:** [X] / 8,000 characters [✅ within limit | ⚠️ close (>6,500) | ❌ over limit] + +### What's working +- [List things that are correctly structured] + +### Issues found +| # | Issue | Severity | Description | +|---|-------|----------|-------------| +| 1 | C1 — Output-only | High | Instructions describe response format but have no workflow for finding answers | +| 2 | A1 — Intent gap | Medium | Email capability is configured but instructions never describe when the agent should search email — no decision logic for this data source | +| 3 | C11 — Token waste | Medium | Tool descriptions duplicated from plugin metadata — reclaim ~800 chars | +| 4 | C6 — Hallucination risk | Medium | "Include policy numbers" but no instruction on where to find them | + +### Recommended structure +[Show the proposed skeleton with sections mapped to their capabilities] + +### Token budget impact +[If rewrite is needed, estimate: current length → projected length after fix] +``` + +### Phase 5: Rewrite (only after user confirms) + +Follow **Detect → Inform → Ask** — the same protocol used for JSON errors. Present the diagnosis, propose the fix, wait for approval before rewriting. + +When rewriting: +- Preserve any existing content that passes the checklist +- Incorporate the domain context and clarifications gathered in Phase 2 — use the user's own terminology and process descriptions +- Do NOT invent domain-specific content (policy names, SharePoint URLs, process details) — ask the user +- Structure using the process-focused pattern: OBJECTIVE → DECISION LOGIC → WORKFLOW → FAILURE HANDLING → RESPONSE RULES → SELF-CHECK +- Ensure every configured capability has clear intent coverage in the instructions with WHEN clauses and chaining rules — built-in capabilities don't need exact names, but actions/plugins should be named +- **Do NOT add tool descriptions or parameters** — these are already in plugin metadata +- **Measure the result** — verify the rewritten instructions are within 8,000 characters. If over, cut in this priority order: + 1. Remove any remaining tool descriptions/parameter lists (C11) + 2. Replace verbose tone blocks with a single output profile reference (D6) + 3. Consolidate redundant workflow steps + 4. If still over, ask the user what to prioritize + +--- + +## Before/After Examples + +### Example 1: Knowledge Base Agent + +**Capabilities configured:** `OneDriveAndSharePoint` (scoped to `/sites/IT-KB/Documents`), `WebSearch` (scoped to `docs.contoso.com`) + +**❌ Before (output-focused):** +```md +You are an IT knowledge base assistant. Help users find answers to technical questions. +Be thorough but concise. Use bullet points when listing steps. Always be professional +and patient. If you don't know the answer, say so politely. +``` + +**Issues:** C1 (output-only), A1 (two capabilities configured, zero intent coverage — instructions don't describe when to use any data source), C5 (tool ambiguity — "find answers" doesn't say where), C6 (no sourcing strategy) + +**✅ After (process-focused — decision logic only, no tool descriptions):** +```md +# OBJECTIVE +Help employees resolve IT questions by searching internal documentation and company-approved external resources. + +# DECISION LOGIC +- Always search the **IT Knowledge Base** (SharePoint) first — this is the primary source. +- If not found in the KB → search **docs.contoso.com** as a secondary source. +- If not found in either → escalate: "Please submit a ticket to helpdesk@contoso.com or the ServiceNow portal." + +# WORKFLOW + +## Step 1: Understand the question +- **Goal:** Identify what the user needs help with. +- **Action:** If the question is clear, proceed. If vague (e.g., "it's not working"), ask ONE clarifying question: what system, what error, what they were trying to do. +- **Transition:** Once clear → Step 2. + +## Step 2: Search +- **Goal:** Find the answer in internal documentation. +- **Action:** Search IT-KB. If not found, search docs.contoso.com. +- **Transition:** If found → Step 3. If not found in either → escalate. + +## Step 3: Respond +- **Action:** Summarize the solution in numbered steps. Cite the source document name. +- **Constraint:** Do not combine information from multiple documents without noting it. + +# RESPONSE RULES +Tone: Professional +- Cite the source for every answer. +- One clarifying question at a time. +- If multiple solutions exist, present the simplest first. +``` + +--- + +### Example 2: Agent with API Plugin + +**Capabilities configured:** `Email`, API plugin (Repairs API with GET/POST/PATCH/DELETE) + +**❌ Before (output-focused):** +```md +You help manage repair tickets and can access email. Be helpful and professional. +When showing repairs, display them in a clear format with the ticket ID, title, +status, and assignee. For new tickets, confirm the details before creating them. +``` + +**Issues:** C1 (output-only), A2 (Repairs API not explained), C5 ("can access email" — when? for what?), B3 (no decision rules for CREATE vs SEARCH vs UPDATE), E1 (no function-level WHEN clauses), E3 (no multi-turn collection), E4 (no confirmation for destructive ops) + +**✅ After (process-focused — decision logic only, no tool descriptions):** +```md +# OBJECTIVE +Help users search, create, and manage repair tickets. Use email context when relevant to a repair. + +# DECISION LOGIC +- When user asks to find repairs → search by keyword or ID. If no results, offer to create one. +- When user reports a new issue → collect title, description, and assignee first. Confirm details before creating. +- When user asks to update a repair → identify the ticket first (by ID or search), confirm what to change, then update. +- When user asks to delete → identify the ticket, **always confirm before deleting**. +- Use **Email** ONLY when: (a) the user mentions an email about a repair, or (b) you need a reference number from prior correspondence. + +# FAILURE HANDLING +- No results from search → ask user to rephrase or offer to create a new ticket. +- User provides incomplete info for creation → ask for missing values one at a time. +- Ambiguous intent (search vs create) → ask: "Would you like me to search for an existing repair or create a new one?" + +# RESPONSE RULES +Tone: Professional +- Always show the ticket ID when referencing a repair. +- Confirm before any destructive operation. +``` + +--- + +### Example 3: MCP Server Agent + +**Capabilities configured:** MCP plugin (Microsoft Docs with `docs_search` tool) + +**❌ Before (output-focused):** +```md +You are a documentation search assistant. Help users find relevant Microsoft +documentation. Provide clear, well-organized responses. Include links when available. +Summarize key points from the documentation you find. +``` + +**Issues:** C1 (output-only), A2 (MCP tool not referenced), C6 ("include links when available" — where do they come from?), C9 (no reasoning calibration), D3 (no explicit tone/format — GPT 5.2 will infer inconsistently), D5 (no validation step) + +**✅ After (process-focused — decision logic only, no tool descriptions):** +```md +# OBJECTIVE +Help users find and understand official Microsoft documentation. + +# DECISION LOGIC +- For factual questions → search docs, summarize the most relevant result with title and link. +- For comparisons ("X vs Y") → search for each topic separately, present side-by-side citing both. +- For troubleshooting ("error Z") → search with the error message first, then by product + "known issues" if no match. +- If no results after two searches → tell the user: "I couldn't find official documentation. Try browsing https://learn.microsoft.com directly." + +# GROUNDING RULES +- Never answer from general knowledge — always search first. +- Cite the source document for every claim. +- Do not stitch information across documents without noting it. + +# RESPONSE RULES +Tone: Efficient +Reasoning: Short answer for simple lookups. Break into steps for troubleshooting. + +# SELF-CHECK +Before responding: confirm you searched, cited a source, and answered the actual question asked. +``` + +--- + +## Stabilizing Header — Interim Fix for Model Drift + +If an agent that worked on GPT 5.0 shows unexpected behavior on GPT 5.2 (reordered steps, added reasoning, tone drift, merged tasks), add this header at the **top** of `instructions.txt` as an immediate stabilization: + +```md +Always interpret instructions literally. +Never infer intent or fill in missing steps. +Never add context, recommendations, or assumptions. +Follow step order exactly with no optimization. +Respond concisely and only in the requested format. +Do not call tools unless a step explicitly instructs you to do so. +``` + +**This is a temporary fix**, not a long-term solution. Use it to stabilize behavior while you rewrite the instructions using the process-focused structure from this guide. Once the instructions are properly structured with explicit workflows, remove the header — well-structured instructions don't need it, and the header prevents GPT 5.2 from using its adaptive reasoning which can be beneficial for open-ended tasks. + +> **Reference:** [Pattern 8 — Literal-execution header](https://learn.microsoft.com/en-us/microsoft-365/copilot/extensibility/declarative-agent-instructions#pattern-8-apply-a-literal-execution-header-for-immediate-stability) + +--- + +## Structured Evaluation Prompt + +For rapid auditing of existing instructions — especially when migrating from GPT 5.0 to 5.2 or reviewing multiple agents at scale — use this structured evaluation prompt. Paste the agent's current instructions into the `` block and run the analysis: + +```md +You are reviewing declarative agent instructions for GPT 5.2 stability. + +INPUT + +[PASTE CURRENT INSTRUCTIONS] + + +TASK +Concise audit. Identify ONLY issues and exact fixes. + +CHECKS +- Step order: identify ambiguity, missing steps, or merged steps → propose atomic, numbered steps. +- Tool use: identify auto-calls, retries, or tool switching → add "use only in step X; no auto-retry". +- Grounding: detect inference, blending, or citation gaps → add "cite only retrieved; no inference; no cross-document stitching". +- Missing-data handling: if retrieval is empty or conflicting → add "stop and ask the user". +- Verbosity: identify chatty or explanatory output → replace with "return only the requested data/format". +- Contradictions or duplicates: resolve discrepancies; prefer explicit over implied. +- Vague verbs ("verify", "process", "handle", "clean"): replace with precise, observable actions. +- Safety: prohibit step reordering, optimization, or reinterpretation. +- Reasoning calibration: match reasoning depth to task type (fast extraction vs deep analysis). +- API plugin functions: verify each function has a WHEN clause, multi-turn parameter collection, and confirmation gates for writes. + +OUTPUT (concise) +- Header patch (3–6 lines) — stabilizing header if needed +- Top 5 changes (bullet list: "Issue → Fix") +- Example rewrite (≤10 lines) for the riskiest step +``` + +> **Reference:** [Pattern 9 — Evaluate and migrate existing instructions](https://learn.microsoft.com/en-us/microsoft-365/copilot/extensibility/declarative-agent-instructions#pattern-9-evaluate-and-migrate-existing-declarative-agent-instructions) + +--- + +## API Plugin Chaining Patterns + +When an agent has API plugins, instructions must cover how functions chain together. Missing chaining instructions cause the agent to treat each function as isolated, forcing the user to manually bridge between calls. + +### Pattern: Output-as-Input Chaining + +Use the result of one API call as input for another: + +```md +To get the weather, always use the `getWeather` action, then create a task with +the title "temperature in [location]: [temperature]" by calling `createTask`. +``` + +### Pattern: Conversation-History Chaining + +Use prior responses to handle follow-up actions: + +```md +1. When the user asks to list all to-dos, call `getTasks` to retrieve the list with title and ID. +2. After listing, if the user asks to delete a to-do by name, use the ID from the previous response to call `deleteTask`. +``` + +### Pattern: Cross-Capability Chaining (SharePoint + API) + +Combine knowledge sources with API actions: + +```md +- To get project statuses, use SharePoint knowledge from **ProjectDeadlines**. +- Always create a to-do for each project using the status update for the title by calling `createTask`. +``` + +### Pattern: Capability + Code Interpreter Chaining + +Process API output with code interpreter: + +```md +When the user asks to list all to-dos, call `getTasks` to retrieve the list, +then use code interpreter to generate a chart based on the output. +``` + +### Pattern: Multi-Turn Parameter Collection + +When a function requires multiple parameters, instruct the agent to collect all values before calling: + +```md +If the user asks about the weather: +1. Ask the user for location. +2. Ask the user for forecast day. +3. Ask the user for unit system (Metric or Imperial). +4. Only call `getWeather` when you have all three values. +``` + +**Anti-pattern:** Letting the agent call the function with partial parameters and handle errors — this produces a poor user experience. + +--- + +## Domain Vocabulary + +Define specialized terms, formulas, acronyms, and dataset-specific language in a dedicated section. This prevents GPT 5.2 from incorrectly inferring definitions. + +```md +# VOCABULARY +- **ROI** — Return on Investment. Calculate as: (Benefit - Cost) / Cost. Do not use any other formula. +- **SLA** — Service Level Agreement. In this context, refers to the 4-hour response time commitment. +- **P1/P2/P3** — Priority levels. P1 = production down, P2 = degraded, P3 = cosmetic/minor. +- **CSAT** — Customer Satisfaction score, on a 1-5 scale. Do not invent definitions; use only these. +``` + +--- + +## Minimum Quality Bar + +Instructions pass the quality bar when ALL of these are true: + +1. **Every configured capability has clear intent coverage in the instructions** — the instructions describe when and why the agent should use each data source. Built-in capabilities do NOT need to be referenced by their exact manifest name (M365 Copilot uses internal names); what matters is clear decision logic. Actions/plugins SHOULD be referenced by name. +2. **At least one workflow exists** with Goal → Action → Transition (or equivalent decision rules) +3. **Failure cases are handled** — the instructions say what to do when a search returns nothing, a tool fails, or the user's question is ambiguous +4. **Output-focused content is ≤20% of the total** — tone, format, and style rules exist but don't dominate +5. **No hallucination invitations** — every "include X" statement has a corresponding "retrieve X from Y" instruction +6. **No tool/parameter duplication** — instructions contain decision logic only; tool descriptions, parameters, and schemas live in plugin metadata +7. **Within the 8,000-character limit** — if instructions exceed this, cut tool descriptions first, then consolidate verbose workflows +8. **Reasoning depth is calibrated** — fast extraction tasks say "short answer only"; analysis tasks say "break the problem into steps" +9. **Markdown structure is clean** — sections use `##`, parallel tasks use bullets, sequential workflows use numbered steps; no mixed list types +10. **API plugin functions have WHEN clauses, chaining rules, and confirmation gates** (if applicable) +11. **A self-evaluation gate exists** — instructions end with "Before responding, confirm: [checklist]" +12. **No GPT 5.2 model-sensitivity anti-patterns** — no fused tasks, no incorrect numbering, no vague verbs, no verbose tone blocks + +If any of these fail, the instructions need improvement before deployment. diff --git a/plugins/microsoft-365-agents-toolkit/skills/declarative-agent-developer/references/localization.md b/plugins/microsoft-365-agents-toolkit/skills/declarative-agent-developer/references/localization.md new file mode 100644 index 0000000..3731fe0 --- /dev/null +++ b/plugins/microsoft-365-agents-toolkit/skills/declarative-agent-developer/references/localization.md @@ -0,0 +1,388 @@ +# Localization Workflow + +## Quick Reference — Complete Localization Checklist + +**For adding localization to an agent (Workflow A):** + +1. ⛔ Tokenize `declarativeAgent.json` → replace `name`, `description`, all `conversation_starters[].title` and `.text` with `[[token]]` syntax +2. ⛔ Create language files → `en.json` (default) + one per additional language, each with `name.short`, `name.full`, `description.short`, `description.full`, and `localizationKeys` mapping EVERY token +3. ⛔ Update `manifest.json` → add `localizationInfo` with `defaultLanguageTag`, `defaultLanguageFile`, `additionalLanguages` +4. ⛔ Deploy → `npx -y --package @microsoft/m365agentstoolkit-cli atk provision --env local --interactive false` + +**For adding a language to an already-localized agent (Workflow B):** + +1. Read existing default language file to get the list of `localizationKeys` +2. Create new `{lang}.json` with the SAME set of keys, translated values +3. Add new entry to `additionalLanguages` in `manifest.json` +4. ⛔ Deploy + +--- + +This document provides step-by-step instructions for localizing an M365 Copilot declarative agent into multiple languages. + +Localization spans two layers: the **app manifest** (`manifest.json`) and the **declarative agent manifest** (`declarativeAgent.json`). Both use the same set of language files, but reference strings differently. + +> **Important:** If an agent supports more than one language, you must provide a separate language file for **every** supported language, including the default language. Single-language agents do not require language files. + +--- + +## ⛔ STOP — READ THIS FIRST + +### Two Localization Scenarios + +| Scenario | What to do | +|----------|------------| +| **Agent has NO localization yet** (no `localizationInfo` in `manifest.json`, no `[[tokens]]` in `declarativeAgent.json`) | Follow **Workflow A: Add Localization to an Agent** — you must tokenize, externalize instructions, create ALL language files, and update `manifest.json` | +| **Agent is ALREADY localized** (`localizationInfo` exists, manifests use `[[tokens]]`) | Follow **Workflow B: Add a Language to an Already-Localized Agent** — you only create a new language file and update `localizationInfo` | + +**⛔ MANDATORY:** You MUST check which scenario applies BEFORE making any changes. Read `manifest.json` to see if `localizationInfo` exists, and read `declarativeAgent.json` to see if it already uses `[[token]]` syntax. + +### ⛔ Anti-Patterns — NEVER Do These + +| ❌ Anti-Pattern | Why It Fails | ✅ Correct Approach | +|----------------|-------------|---------------------| +| Replacing strings directly with translated text in `declarativeAgent.json` | Hardcoded translations don't support multi-language switching. Only one language works at a time. | Use `[[token]]` syntax and create language files with `localizationKeys`. | +| Leaving instructions inline in `declarativeAgent.json` when localizing | Instructions must be separated from localizable content. Inline instructions block proper tokenization. | Create `appPackage/instructions.txt` and set `"instructions": "$[file]('instructions.txt')"`. | +| Creating language files without tokenizing `declarativeAgent.json` first | Language file `localizationKeys` are only resolved when the manifest uses `[[token]]` syntax. Without tokens, the language files have no effect. | Always tokenize the manifest BEFORE creating language files. | +| Using `[[token]]` for the instructions field | Instructions are NOT localizable. The LLM consumes them in a single language. | Use `$[file]('instructions.txt')` for instructions. Never tokenize them. | +| Setting `defaultLanguageTag` to a non-English language without an `en.json` fallback | The default language must have the default language file. English should typically be the default. | Set `defaultLanguageTag: "en"` and `defaultLanguageFile: "en.json"`. Add other languages as `additionalLanguages`. | + +### How Localization Works + +| Layer | Key style | Example | +|-------|-----------|---------| +| App manifest (`manifest.json`) | JSONPath expressions | `name.short`, `description.full` | +| Agent / plugin manifests | Double-bracket tokens resolved via `localizationKeys` | `[[agent_name]]`, `[[plugin_description]]` | + +Both types of localized strings live in the **same** language file per locale. + +--- + +## Workflow A: Add Localization to an Agent + +Use this workflow when localizing an agent for the **first time** — the agent currently has hardcoded strings and no `localizationInfo`. + +### Step A1: Tokenize Agent Manifests — MANDATORY + +Replace ALL user-facing strings in `declarativeAgent.json` (and `plugin.json`, if applicable) with tokenized keys wrapped in double brackets (`[[key_name]]`). + +**You MUST tokenize ALL of these fields:** + +| Field | Token example | +|-------|---------------| +| `name` | `[[agent_name]]` | +| `description` | `[[agent_description]]` | +| Every `conversation_starters[].title` | `[[starter_travel_title]]`, `[[starter_remote_title]]`, etc. | +| Every `conversation_starters[].text` | `[[starter_travel_text]]`, `[[starter_remote_text]]`, etc. | +| `disclaimer.text` (if present) | `[[disclaimer_text]]` | + +**Token key rules:** +- Must match the pattern: `^[a-zA-Z_][a-zA-Z0-9_]*$` +- Use descriptive, snake_case names (e.g., `starter_vpn_title` not `key1`) +- Keep names consistent across agent and plugin manifests + +**Example — tokenized `declarativeAgent.json`:** + +```json +{ + "$schema": "https://developer.microsoft.com/json-schemas/copilot/declarative-agent/v1.6/schema.json", + "version": "v1.6", + "name": "[[agent_name]]", + "description": "[[agent_description]]", + "instructions": "$[file]('instructions.txt')", + "conversation_starters": [ + { + "title": "[[starter_vpn_title]]", + "text": "[[starter_vpn_text]]" + }, + { + "title": "[[starter_password_title]]", + "text": "[[starter_password_text]]" + } + ], + "disclaimer": { + "text": "[[disclaimer_text]]" + } +} +``` + +**If API plugin exists — tokenize `plugin.json` too:** + +```json +{ + "schema_version": "v2.4", + "name_for_human": "[[plugin_name]]", + "description_for_human": "[[plugin_description]]", + "description_for_model": "[[plugin_model_description]]" +} +``` + +**⛔ NEVER skip tokenization.** Writing localization files without first tokenizing the manifests makes localization non-functional. The manifests MUST use `[[token]]` syntax for the language files to take effect. + +**✅ POST-TOKENIZATION CHECKPOINT — Verify before proceeding to Step A2:** +- [ ] `name` field uses `[[agent_name]]` or similar token +- [ ] `description` field uses `[[agent_description]]` or similar token +- [ ] Every `conversation_starters[].title` uses a `[[token]]` +- [ ] Every `conversation_starters[].text` uses a `[[token]]` +- [ ] `instructions` field is NOT tokenized (it should remain as `$[file]('instructions.txt')` or inline text — never `[[token]]`) + +**If any box is unchecked, STOP and fix it before continuing.** + +### Step A2: Create Language Files — MANDATORY + +Create one JSON file per language in `appPackage/`, named `{languageTag}.json` (e.g., `en.json`, `fr.json`, `ja.json`). + +**Every language file MUST contain:** + +1. **`$schema`** — The localization schema reference +2. **App manifest strings** — `name.short`, `name.full`, `description.short`, `description.full` (all four are REQUIRED) +3. **`localizationKeys` object** — One entry per `[[token]]` used in `declarativeAgent.json` and `plugin.json`, using the token name WITHOUT brackets + +**Default language file (`en.json`) — use the ORIGINAL English values from the agent:** + +```json +{ + "$schema": "https://developer.microsoft.com/json-schemas/teams/vDevPreview/MicrosoftTeams.Localization.schema.json", + "name.short": "IT Help Desk", + "name.full": "IT Help Desk Agent", + "description.short": "Resolve common IT issues", + "description.full": "Helps employees resolve common IT issues using internal knowledge bases and ticketing systems.", + "localizationKeys": { + "agent_name": "IT Help Desk Agent", + "agent_description": "Helps employees resolve common IT issues using internal knowledge bases and ticketing systems.", + "starter_vpn_title": "VPN issues", + "starter_vpn_text": "I can't connect to the corporate VPN. What should I try?", + "starter_password_title": "Password reset", + "starter_password_text": "How do I reset my password?", + "disclaimer_text": "This agent provides general IT guidance. For urgent issues, contact the helpdesk directly." + } +} +``` + +**Additional language file (`fr.json`) — use translations provided by the user:** + +```json +{ + "$schema": "https://developer.microsoft.com/json-schemas/teams/vDevPreview/MicrosoftTeams.Localization.schema.json", + "name.short": "Support informatique", + "name.full": "Agent de support informatique", + "description.short": "Résoudre les problèmes informatiques courants", + "description.full": "Aide les employés à résoudre les problèmes informatiques courants à l'aide des bases de connaissances internes.", + "localizationKeys": { + "agent_name": "Agent de support informatique", + "agent_description": "Aide les employés à résoudre les problèmes informatiques courants à l'aide des bases de connaissances internes.", + "starter_vpn_title": "Problèmes de VPN", + "starter_vpn_text": "Je n'arrive pas à me connecter au VPN de l'entreprise. Que dois-je essayer ?", + "starter_password_title": "Réinitialisation du mot de passe", + "starter_password_text": "Comment réinitialiser mon mot de passe ?", + "disclaimer_text": "Cet agent fournit des conseils informatiques généraux. Pour les problèmes urgents, contactez le service d'assistance directement." + } +} +``` + +**⛔ CRITICAL:** The `localizationKeys` in EVERY language file must have the EXACT SAME set of keys. If `en.json` has `agent_name`, `agent_description`, `starter_vpn_title`, etc., then `fr.json` must also have ALL of those same keys. Missing keys cause runtime resolution failures. + +**If the user did not provide translations for some strings** (e.g., conversation starters), you MUST ask the user for them. Do NOT invent translations. + +### Step A3: Add `localizationInfo` to `manifest.json` — MANDATORY + +Add the `localizationInfo` section to `manifest.json`: + +```json +{ + "localizationInfo": { + "defaultLanguageTag": "en", + "defaultLanguageFile": "en.json", + "additionalLanguages": [ + { + "languageTag": "fr", + "file": "fr.json" + }, + { + "languageTag": "es", + "file": "es.json" + } + ] + } +} +``` + +**Rules:** +- `defaultLanguageTag` and `defaultLanguageFile` are always required +- Each additional language needs an entry in `additionalLanguages` +- Language files live in `appPackage/` alongside the manifests +- Use language-only tags (e.g., `en` rather than `en-us`) for top-level translations; add region-specific overrides only when needed + +### Step A4: Deploy — MANDATORY + +After completing ALL localization changes, deploy the agent: + +```bash +npx -y --package @microsoft/m365agentstoolkit-cli atk provision --env local --interactive false +``` + +Then read `M365_TITLE_ID` from `env/.env.local` and present the test link. **⛔ Never skip deployment after localization changes.** + +--- + +## Workflow B: Add a Language to an Already-Localized Agent + +Use this workflow when the agent is ALREADY localized (manifests use `[[tokens]]`, `localizationInfo` exists, language files exist) and the user wants to add another language. + +**⛔ Do NOT re-tokenize manifests or recreate existing language files.** Only create the NEW language file and update `localizationInfo`. + +### Step B1: Read Existing Localization Setup + +1. Read `manifest.json` — note the `localizationInfo` section (default language, existing additional languages) +2. Read the default language file (e.g., `en.json`) — note ALL keys in `localizationKeys` (these are the keys the new file must also have) +3. Read `declarativeAgent.json` — confirm it uses `[[token]]` syntax (if not, switch to Workflow A) + +### Step B2: Create the New Language File + +Create `appPackage/{languageTag}.json` with: + +1. `$schema` — same as existing language files +2. `name.short`, `name.full`, `description.short`, `description.full` — translated values from the user +3. `localizationKeys` — one entry per key from the default language file, with translated values from the user + +**⛔ CRITICAL:** The new file MUST have the EXACT SAME set of `localizationKeys` as the default language file. Copy the key names from the existing default language file and fill in translated values. + +### Step B3: Update `localizationInfo` in `manifest.json` + +Add the new language to the `additionalLanguages` array: + +```json +{ + "languageTag": "pt-BR", + "file": "pt-BR.json" +} +``` + +**⛔ Do NOT modify existing language files or the `defaultLanguageFile` entry.** Only add to `additionalLanguages`. + +### Step B4: Deploy — MANDATORY + +Deploy the agent: + +```bash +npx -y --package @microsoft/m365agentstoolkit-cli atk provision --env local --interactive false +``` + +Then present the test link. **⛔ Never skip deployment.** + +--- + +## Localizable Fields Reference + +**Declarative agent manifest:** + +| Field | Description | Max length | Required | +|---|---|---|---| +| `name` | Display name of the agent | 100 chars | ✔️ | +| `description` | Description shown to users | 1,000 chars | ✔️ | +| `conversation_starters[].title` | Short title for a conversation starter | — | | +| `conversation_starters[].text` | Full prompt text for a conversation starter | — | | +| `disclaimer.text` | Disclaimer shown at conversation start | — | | + +**API plugin manifest:** + +| Field | Description | Max length | Required | +|---|---|---|---| +| `name_for_human` | Short, human-readable plugin name | 20 chars | ✔️ | +| `description_for_human` | Human-readable description | 100 chars | ✔️ | +| `description_for_model` | Description provided to the model | 2,048 chars | | +| `conversation_starters[].title` | Title for plugin conversation starters | — | | +| `conversation_starters[].text` | Text for plugin conversation starters | — | | + +### Required App Manifest Keys in Every Language File + +| Key | Description | Max length | Required | +|---|---|---|---| +| `name.short` | Short app name | 30 chars | ✔️ | +| `name.full` | Full app name | 100 chars | ✔️ | +| `description.short` | Short app description | 80 chars | ✔️ | +| `description.full` | Full app description | 4,000 chars | ✔️ | + +--- + +## Language Resolution Order + +The Microsoft 365 host resolves strings in the following order: + +1. Start with the **default language** strings +2. Overwrite with the user's **language-only** file (e.g., `en`) +3. Overwrite with the user's **language + region** file (e.g., `en-gb`), if available + +For example, if the default language is `fr`, and you provide `en` and `en-gb` files, a user with locale `en-gb` sees: `fr` → overwritten by `en` → overwritten by `en-gb`. + +> **Tip:** Provide top-level, language-only translations (e.g., `en` rather than `en-us`). Add region-specific overrides only for the few strings that need them. + +--- + +## Project Structure + +A localized app package includes the language files alongside the manifests: + +```text +my-agent/ +├── appPackage/ +│ ├── manifest.json +│ ├── declarativeAgent.json +│ ├── instructions.txt # externalized instructions (NOT tokenized) +│ ├── plugin.json # optional +│ ├── en.json # default language file +│ ├── fr.json # French language file +│ ├── es.json # Spanish language file +│ ├── color.png +│ └── outline.png +├── env/ +│ └── .env.dev +└── m365agents.yml +``` + +--- + +## Critical Rules + +1. **Every `[[token]]` must have a matching `localizationKeys` entry** in every language file. Missing keys cause runtime failures. +2. **Keep token names descriptive** — use `starter_vpn_title` not `key1`. +3. **Do NOT localize instructions** — externalize to `instructions.txt` via `$[file]('instructions.txt')`. Never use `[[tokens]]` for instructions. +4. **Schema version consistency** — `$schema` in language files must match `manifest.json`. +5. **Always deploy after localization changes.** +6. **Do NOT invent translations** — ask the user for translated strings. Never machine-translate without confirmation. +7. **Tokenization is MANDATORY** — language files have no effect without `[[token]]` syntax in the manifests. + +--- + +## ⛔ FINAL GATE — Before Responding to the User + +**STOP.** Before writing your response, verify ALL of the following: + +- [ ] `declarativeAgent.json` uses `[[token]]` syntax for ALL localizable fields (name, description, every conversation starter title/text, disclaimer) +- [ ] `instructions` field is NOT tokenized (never use `[[token]]` for instructions) +- [ ] A default language file exists (e.g., `en.json`) with `name.short`, `name.full`, `description.short`, `description.full`, and ALL `localizationKeys` +- [ ] Every additional language file has the EXACT SAME set of `localizationKeys` as the default +- [ ] `manifest.json` has `localizationInfo` with `defaultLanguageTag`, `defaultLanguageFile`, and `additionalLanguages` +- [ ] I deployed with `npx -y --package @microsoft/m365agentstoolkit-cli atk provision --env local --interactive false` +- [ ] I presented the test link + +**If you cannot check ALL boxes, you are NOT done.** Go back and complete the missing steps. + +--- + +## Error Handling + +| Error | Action | +|-------|--------| +| Token in manifest has no matching `localizationKeys` entry | **Stop.** List the missing keys and the language files that need them. Ask the user to provide the translations. | +| Language file referenced in `localizationInfo` doesn't exist | **Stop.** List the missing files. Ask the user to provide them or remove the language from `additionalLanguages`. | +| `localizationInfo.defaultLanguageFile` is missing | **Stop.** Inform the user that a default language file is required when `localizationInfo` is present. | +| Agent has only one language | Inform the user that language files are not required for single-language agents. Ask if they want to add more languages. | + +--- + +## Learn More + +- [Localize your agent](https://learn.microsoft.com/en-us/microsoft-365-copilot/extensibility/localize-agents) — Official Microsoft localization guide for agents +- [Localize your app (Microsoft Teams)](https://learn.microsoft.com/en-us/microsoftteams/platform/concepts/build-and-test/apps-localization) — General Teams app localization reference +- [Localization schema reference](https://learn.microsoft.com/en-us/microsoftteams/platform/resources/schema/localization-schema) — JSON schema for localization files diff --git a/plugins/microsoft-365-agents-toolkit/skills/declarative-agent-developer/references/mcp-plugin.md b/plugins/microsoft-365-agents-toolkit/skills/declarative-agent-developer/references/mcp-plugin.md new file mode 100644 index 0000000..4d601b5 --- /dev/null +++ b/plugins/microsoft-365-agents-toolkit/skills/declarative-agent-developer/references/mcp-plugin.md @@ -0,0 +1,722 @@ +# MCP Server Plugin Integration + +This guide explains how to integrate Model Context Protocol (MCP) servers as actions in your Microsoft 365 Copilot agent using JSON manifests. It covers both unauthenticated and OAuth-authenticated MCP servers. + +> **⛔ SINGLE FILE ONLY:** MCP plugins require exactly **ONE file** — the plugin manifest (`{name}-plugin.json`). Tool descriptions are inlined directly in the manifest's `mcp_tool_description.tools` array. **Do NOT create a separate `{name}-mcp-tools.json` file.** There is no `"file"` property — only `"tools": [...]`. + +## Overview + +MCP servers expose tools that can be consumed by your agent. Unlike OpenAPI-based plugins, MCP plugins use a `RemoteMCPServer` runtime type and embed the tool descriptions directly in the plugin manifest. + +> **⚠️ IMPORTANT:** `npx -y --package @microsoft/m365agentstoolkit-cli atk add action` does NOT support MCP servers — it only supports `--api-plugin-type api-spec` for OpenAPI plugins. MCP plugins MUST be created manually following the steps below. This is NOT a violation of the "Always Use `npx -y --package @microsoft/m365agentstoolkit-cli atk add action`" rule — that rule applies only to OpenAPI/REST API plugins. + +## Prerequisites + +- MCP server URL (must be accessible via HTTP/HTTPS) +- Node.js installed (for `mcp-remote` authentication helper) +- Logo images for the agent (color.png 192×192 and outline.png 32×32) — optional, see [Step 5: Logo Images](#step-5-logo-images-optional) + +--- + +## Scaffold the Agent Project First + +Before adding an MCP plugin, you **must** have a scaffolded agent project. Run `npx -y --package @microsoft/m365agentstoolkit-cli atk new` if you haven't already: + +```bash +npx -y --package @microsoft/m365agentstoolkit-cli atk new \ + -n my-agent \ + -c declarative-agent \ + -i false +``` + +This creates `m365agents.yml` (and `m365agents.local.yml`) with the **5 required lifecycle steps**: + +| Step | Lifecycle Action | What it does | +|------|-----------------|--------------| +| 1 | `teamsApp/create` | Registers the Teams app | +| 2 | `teamsApp/zipAppPackage` | Packages manifest + icons into a zip | +| 3 | `teamsApp/validateAppPackage` | Validates the package (icons, schema, etc.) | +| 4 | `teamsApp/update` | Uploads the package to Teams | +| 5 | `teamsApp/extendToM365` | **Extends the app to M365 Copilot** — generates `M365_TITLE_ID` | + +**What breaks without `extendToM365`:** If this step is missing, `npx -y --package @microsoft/m365agentstoolkit-cli atk provision` will register the Teams app and generate `TEAMS_APP_ID`, but the agent will **never appear in Copilot Chat** because no `M365_TITLE_ID` is generated. This is the most common reason for "provision succeeded but agent not found" failures. + +> **If you already have a project** but are missing `teamsApp/extendToM365`, add it to the `provision` lifecycle in `m365agents.yml` after `teamsApp/update`. See [deployment.md](deployment.md) for the full provisioning reference. + +--- + +## Step-by-Step Integration + +### Step 1: Get MCP Server URL + +Ask the user for the MCP server URL. Example: `https://learn.microsoft.com/api/mcp` + +Derive the **server root** (scheme + host only): e.g., `https://learn.microsoft.com` + +### Step 2: Detect Authentication Requirements + +Before discovering tools, determine if the MCP server requires OAuth authentication. + +**Probe both well-known endpoints in parallel:** + +```bash +curl -s /.well-known/oauth-authorization-server +curl -s /.well-known/openid-configuration +``` + +**Decision:** +- **OAuth metadata found** (either endpoint returns valid JSON with `authorization_endpoint`) → the server requires authentication. Follow [authentication.md](authentication.md) Steps 1-3 to discover endpoints, obtain credentials, and configure `oauth/register` in both `m365agents.yml` and `m365agents.local.yml`. Then continue to [Step 3](#step-3-discover-mcp-tools-mandatory) below for authenticated tool discovery. +- **No OAuth metadata** (both return 404 or non-JSON) → the server is unauthenticated. Skip directly to [Step 3](#step-3-discover-mcp-tools-mandatory) for unauthenticated tool discovery. + +### Step 3: Discover MCP Tools (MANDATORY) + +🚨 **THIS STEP IS MANDATORY — DO NOT SKIP** + +You MUST discover tools via the MCP protocol directly. Tool discovery uses HTTP POST requests to the MCP server URL. + +#### 3a. Authenticate (OAuth servers only) + +If the server requires OAuth (detected in Step 2), perform a one-time authentication: + +Tell the user: +> "I need to authenticate with [name]'s MCP server. A browser window will open — please sign in." + +Run the command **interactively** (NOT backgrounded — do NOT append `&` or redirect to files): + +```bash +npx -p mcp-remote@latest mcp-remote-client --port 3334 +``` + +Wait for it to complete. The command will open a browser for OAuth sign-in and then exit once authentication succeeds. + +> **WSL / headless environments:** `mcp-remote` starts a local HTTP server for the OAuth callback and tries to open a browser. In WSL, the browser opens on the Windows host but the `http://127.0.0.1:3334/...` callback URL may not route back to WSL. If the browser opens but authentication seems stuck: +> 1. After signing in, copy the full callback URL from the browser (it will show an error or blank page) +> 2. Run `curl ''` inside WSL to deliver the auth code to mcp-remote +> 3. Alternatively, run `export BROWSER=wslview` before the command so WSL's browser opener is used, which handles the redirect correctly + +**⛔ IMPORTANT:** Do NOT look for tokens in `/tmp/` or log files. Tokens are ONLY stored at `~/.mcp-auth/`. + +**Read the cached access token from `~/.mcp-auth`:** + +```bash +ls ~/.mcp-auth/mcp-remote-*/ +``` + +Find the token file (pattern: `{url-hash}_tokens.json`), read it, and extract `access_token`. + +**⛔ Security:** Do NOT print the token value in your output. Extract it silently and use it only in subsequent HTTP calls. Do NOT write it to any file or create copies. + +#### 3b. MCP Session Handshake + +Run three sequential HTTP calls to discover tools. + +**⛔ Security:** Suppress raw HTTP responses that may contain tokens. Only extract the fields you need (`mcp-session-id`, tool definitions). Do NOT display Authorization headers or token values to the user. + +**Call 1 — Initialize:** + +```bash +curl -s -X POST \ + -H "Content-Type: application/json" \ + -H "Accept: application/json, text/event-stream" \ + [-H "Authorization: Bearer "] \ + -D - \ + -d '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18","capabilities":{},"clientInfo":{"name":"m365-agent-skill","version":"1.0.0"}}}' +``` + +Extract `mcp-session-id` from the response headers. Omit the `Authorization` header for unauthenticated servers. + +**Call 2 — Initialized notification:** + +```bash +curl -s -X POST \ + -H "Content-Type: application/json" \ + -H "mcp-session-id: " \ + [-H "Authorization: Bearer "] \ + -d '{"jsonrpc":"2.0","method":"notifications/initialized"}' +``` + +**Call 3 — List tools (with pagination):** + +```bash +curl -s -X POST \ + -H "Content-Type: application/json" \ + -H "mcp-session-id: " \ + [-H "Authorization: Bearer "] \ + -d '{"jsonrpc":"2.0","id":2,"method":"tools/list","params":{}}' +``` + +If the response contains `nextCursor`, repeat with `{"params":{"cursor":""}}` until no cursor remains. Collect all tools. + +**Extracting tools from the response:** + +Save the raw tools/list response to a file, then use this script to extract the tools array: + +```bash +python3 << 'EXTRACT_TOOLS' +import json, sys + +with open("/tmp/mcp-tools-response.json") as f: + data = json.load(f) + +tools = data.get("result", {}).get("tools", []) +with open("/tmp/mcp-tools.json", "w") as out: + json.dump(tools, out, indent=2) + +print(f"Extracted {len(tools)} tools") +for t in tools: + print(f" - {t['name']}: {t.get('description', '')[:80]}") +EXTRACT_TOOLS +``` + +> **⛔ Do NOT use inline Python inside command substitutions** (e.g., `$(python3 -c '...')`). The shell security policy blocks nested command substitutions. Always use heredoc scripts (`<< 'EOF'`) or standalone `.py` files instead. + +**Expected output structure:** +```json +{ + "result": { + "tools": [ + { + "name": "tool_name", + "description": "Tool description", + "inputSchema": { + "type": "object", + "properties": { ... }, + "required": [...] + }, + "annotations": { + "readOnlyHint": true + }, + "execution": { + "taskSupport": "forbidden" + }, + "_meta": { + "ui": { + "resourceUri": "ui://namespace/view-name.html" + } + } + } + ] + } +} +``` + +> **⚠️ IMPORTANT:** The example above shows commonly seen fields (`annotations`, `execution`, `_meta`), but MCP servers may return **any** additional properties on tool objects. You **MUST** preserve every property returned by tools/list — copy each tool object in its entirety into `mcp_tool_description.tools[]`. Do NOT cherry-pick known fields; treat the tools/list output as the source of truth and inline it verbatim. + +#### 3c. Use All Discovered Tools + +**Include ALL tools** returned by `tools/list` in the plugin manifest. Do NOT filter or exclude tools unless the developer explicitly asks to limit the tool set. + +**Copy each tool object verbatim** — every property the server returns (`name`, `description`, `inputSchema`, `annotations`, `execution`, `_meta`, `outputSchema`, `title`, or any other field) must be preserved in `mcp_tool_description.tools[]`. Do NOT maintain a hardcoded list of "known" fields — the MCP protocol evolves and servers may return new properties at any time. + +Tell the user how many tools were discovered and confirm they will all be included. + +### Step 4: Create the Plugin Manifest + +Create `{name}-plugin.json` in the `appPackage` folder: + +```json +{ + "$schema": "https://developer.microsoft.com/json-schemas/copilot/plugin/v2.4/schema.json", + "schema_version": "v2.4", + "name_for_human": "{NAME-FOR-HUMAN}", + "description_for_human": "{DESCRIPTION-FOR-HUMAN}", + "namespace": "simplename", + "functions": [], + "runtimes": [] +} +``` + +**Required fields:** +| Field | Description | +|-------|-------------| +| `name_for_human` | Display name shown to users (max 20 characters) | +| `description_for_human` | Brief description of the plugin (max 100 characters) | +| `namespace` | Unique identifier, lowercase alphanumeric only (no hyphens, no underscores) | + +### Step 4a: Add Functions from Discovered Tools + +For EACH discovered tool from Step 3, add a function entry with `name`, `description`, and `capabilities` only. Do **NOT** duplicate `parameters`/`inputSchema` in the function — all tool schema data lives exclusively in `mcp_tool_description.tools[]` (see Step 6). + +```json +{ + "functions": [ + { + "name": "microsoft_docs_search", + "description": "Search official Microsoft/Azure documentation to find the most relevant content for a user's query." + } + ] +} +``` + +**🚨 CRITICAL: Preserve ALL tool properties when creating function entries:** + +| MCP tools/list Output | Plugin Manifest (`functions[]`) | +|---|---| +| `name` | `name` — copy EXACTLY, do not rename | +| `description` | `description` — use the **full** description text, do NOT abbreviate or summarize | +| `inputSchema` | Do NOT add to `functions[]` — this goes in `mcp_tool_description.tools[]` only | +| **Any other property** (`annotations`, `execution`, `_meta`, `outputSchema`, `title`, or any future field) | Do NOT add to `functions[]` — this goes in `mcp_tool_description.tools[]` only | + +> **🔑 Full-fidelity rule:** Every tool object in `mcp_tool_description.tools[]` must be a **verbatim copy** of the corresponding tool from the tools/list response. Copy the entire object as-is — do NOT strip, rename, reorder, or omit any property. The MCP protocol evolves and servers may return fields not listed above. If the server returns it, the plugin must include it. + +**Why this matters:** The model uses `description` from functions to decide when to invoke each tool. The runtime uses the full tool definitions from `mcp_tool_description.tools[]` to actually call the MCP server. These definitions include `inputSchema` (parameters), `annotations` (read-only/destructive hints), `execution` (task support behavior), `_meta` (UI widget resources), and potentially other properties. Stripping any of them breaks runtime behavior or loses capabilities. Do not duplicate schema data in both places — only `name` and `description` go into `functions[]`. + +### Step 4b: Add Response Semantics + +**ALWAYS** add `capabilities.response_semantics` to every function — even if no title or URL fields can be identified. Never omit it. + +For each tool: +1. Check the tool's `outputSchema` field (optional in MCP — present on some servers). If present, read field names from it directly. +2. If `outputSchema` is absent (common), reason from the tool's `description` text to identify which fields are returned. Look for mentions of URL fields (`url`, `link`, `href`) and title fields (`title`, `name`, `label`). +3. If you can confidently identify BOTH a title-like field AND a navigable URL field → use the **rich pattern**. +4. Otherwise → use the **default pattern**. + +**Rich pattern** (when title + URL field are identified): +```json +{ + "name": "tool_name", + "description": "...", + "capabilities": { + "response_semantics": { + "data_path": "$.items", + "properties": { + "title": "$.title", + "url": "$.url" + }, + "static_template": { + "type": "AdaptiveCard", + "$schema": "https://adaptivecards.io/schemas/adaptive-card.json", + "version": "1.6", + "body": [ + { + "type": "TextBlock", + "text": "[${title}](${url})", + "wrap": true, + "maxLines": 2 + } + ] + } + } + } +} +``` + +**Default pattern** (when title or URL cannot be confidently identified): +```json +{ + "name": "tool_name", + "description": "...", + "capabilities": { + "response_semantics": { + "data_path": "$", + "properties": {}, + "static_template": { + "type": "AdaptiveCard", + "$schema": "https://adaptivecards.io/schemas/adaptive-card.json", + "version": "1.6", + "body": [ + { + "type": "TextBlock", + "text": "${if(title, title, description)}", + "wrap": true + } + ] + } + } + } +} +``` + +**Response semantics rules:** +- `$schema`: always `https://adaptivecards.io/schemas/adaptive-card.json` (not `http://`) +- `version`: always `"1.6"` +- Rich template body is always `"[${title}](${url})"` — the title IS the hyperlink +- Source name comes from `name_for_human` automatically — do NOT add it as a TextBlock +- `data_path` and field paths are connector-specific — derive them from the tool's actual response structure + +### Step 5: Logo Images (Optional) + +Logos are **not mandatory**. The default logos from `npx -y --package @microsoft/m365agentstoolkit-cli atk new` work fine. Ask the user casually: + +> "Would you like to use a custom logo for [name], or is the default fine?" + +If the user **does not** have a logo or says to skip → **move on**. Do NOT block the workflow for logos. + +If the user **does** want a custom logo, they need two **PNG** files (no JPG, SVG, or other formats): + +- **`color.png`** — 192×192 px, full colour +- **`outline.png`** — 32×32 px, white-on-transparent + +**Resolving logo inputs — check in this order:** + +1. **URL**: If the user provides a URL, download the image with `curl -L -o `. +2. **Local file path**: If the user provides a path, use it directly. + +**If the provided image is not PNG, convert it to PNG before processing.** + +**Handling missing formats:** +- If the user provides only one image, ask: "I have your [color/outline] logo. For the [other format], would you like to provide it, or shall I generate it automatically?" +- If the user says to generate it, derive it from the provided image using jimp. +- If the user says the provided images already meet the size requirements, skip processing and use them directly. + +**Processing with jimp** (only when resizing or conversion is needed): + +```javascript +// Install: npm install jimp (in a temp directory) +// Import: const { Jimp } = require('jimp'); + +// color.png: resize to 192x192 +// outline.png: resize to 32x32, convert all non-transparent pixels to white on transparent background +``` + +Output files: `appPackage/color.png` (192×192) and `appPackage/outline.png` (32×32 white-on-transparent). + +Show the resulting icon(s) to the user for approval before proceeding. If the user rejects, ask them to provide their own images and do NOT proceed until approved. + +### Step 6: Configure the Runtime + +Add the `RemoteMCPServer` runtime with the tools inlined in `mcp_tool_description.tools`: + +**For authenticated servers** (see [authentication.md](authentication.md)): +```json +{ + "runtimes": [ + { + "type": "RemoteMCPServer", + "auth": { + "type": "OAuthPluginVault", + "reference_id": "${{_MCP_AUTH_ID}}" + }, + "spec": { + "url": "{MCP_SERVER_URL}", + "mcp_tool_description": { + "tools": [ + { + "name": "function_name_1", + "description": "Full tool description from tools/list output", + "inputSchema": { + "type": "object", + "properties": { "..." : "..." }, + "required": ["..."] + }, + "annotations": { "readOnlyHint": true }, + "execution": { "taskSupport": "forbidden" }, + "_meta": { "ui": { "resourceUri": "ui://namespace/view.html" } } + } + ] + } + }, + "run_for_functions": [ + "function_name_1", + "function_name_2" + ] + } + ] +} +``` + +**For unauthenticated servers:** +```json +{ + "runtimes": [ + { + "type": "RemoteMCPServer", + "auth": { + "type": "None" + }, + "spec": { + "url": "{MCP_SERVER_URL}", + "mcp_tool_description": { + "tools": [ ... ] + } + }, + "run_for_functions": [ ... ] + } + ] +} +``` + +> **⚠️ IMPORTANT:** +> - The `mcp_tool_description.tools` array must be a **verbatim copy** of the tools from the tools/list output (Step 3). Copy every property on each tool object exactly as returned — `inputSchema`, `annotations`, `execution`, `_meta`, and any other field the server includes. Do NOT strip, rename, or omit any property. Do NOT use a `file` reference — inline the tools directly. +> - Do NOT fabricate properties that the server did not return. Only include what tools/list actually gives you. +> - For authenticated servers, both `m365agents.yml` and `m365agents.local.yml` must include the `oauth/register` step — see [authentication.md](authentication.md). + +### Step 7: Register Plugin in Agent Manifest + +Add the plugin to your `declarative-agent.json`: + +```json +{ + "actions": [ + { + "id": "mcpPlugin", + "file": "{name}-plugin.json" + } + ] +} +``` + +--- + +## Complete Workflow Checklist + +``` +□ Step 0: Scaffold agent project with `npx -y --package @microsoft/m365agentstoolkit-cli atk new` (if not already scaffolded) ← MANDATORY +□ Step 1: Get MCP server URL from user +□ Step 2: Detect authentication requirements (probe well-known endpoints) +□ → If OAuth: follow authentication.md (discover endpoints, get creds, configure oauth/register) +□ Step 3: Discover tools via MCP protocol (initialize → tools/list) ← MANDATORY +□ → Include ALL tools (do not filter unless developer explicitly requests it) +□ Step 4: Create {name}-plugin.json with functions + response_semantics +□ Step 5: Ask user about custom logo (optional — skip if user declines) +□ Step 6: Add runtime with RemoteMCPServer type (OAuthPluginVault or None) +□ Step 7: Register plugin in declarativeAgent.json +□ Step 8: Run npx -y --package @microsoft/m365agentstoolkit-cli atk provision --env local --interactive false +``` + +--- + +## Complete Example — Unauthenticated Server + +For the Zava Insurance MCP server at `https://zava-insurance-mcp.azurewebsites.net/mcp`: + +### `appPackage/zava-plugin.json` + +```json +{ + "$schema": "https://developer.microsoft.com/json-schemas/copilot/plugin/v2.4/schema.json", + "schema_version": "v2.4", + "name_for_human": "Zava Insurance", + "description_for_human": "Manage insurance claims, inspections, contractors, and purchase orders", + "namespace": "zavainsurance", + "functions": [ + { + "name": "show-claims-dashboard", + "description": "Displays the Zava Insurance claims dashboard showing all claims with status overview, filters, and summary metrics. Supports filtering by status and/or policy holder name. When the user mentions a person's name, first name, last name, or partial name, always pass it as the policyHolderName parameter. The name filter is case-insensitive and supports partial matches.", + "capabilities": { "response_semantics": { "data_path": "$", "properties": {}, "static_template": { "type": "AdaptiveCard", "$schema": "https://adaptivecards.io/schemas/adaptive-card.json", "version": "1.6", "body": [{ "type": "TextBlock", "text": "${if(title, title, description)}", "wrap": true }] } } } + }, + { + "name": "show-claim-detail", + "description": "Displays detailed information about a specific insurance claim including related inspections, purchase orders, and contractor assignments. Use claim ID (e.g. '1', '2') or claim number (e.g. 'CN202504990').", + "capabilities": { "response_semantics": { "data_path": "$", "properties": {}, "static_template": { "type": "AdaptiveCard", "$schema": "https://adaptivecards.io/schemas/adaptive-card.json", "version": "1.6", "body": [{ "type": "TextBlock", "text": "${if(title, title, description)}", "wrap": true }] } } } + }, + { + "name": "show-contractors", + "description": "Displays the list of contractors available for insurance repair work. Optionally filter by specialty or preferred status.", + "capabilities": { "response_semantics": { "data_path": "$", "properties": {}, "static_template": { "type": "AdaptiveCard", "$schema": "https://adaptivecards.io/schemas/adaptive-card.json", "version": "1.6", "body": [{ "type": "TextBlock", "text": "${if(title, title, description)}", "wrap": true }] } } } + }, + { + "name": "update-claim-status", + "description": "Updates the status of an insurance claim. Use claim ID (e.g. '1', '2').", + "capabilities": { "response_semantics": { "data_path": "$", "properties": {}, "static_template": { "type": "AdaptiveCard", "$schema": "https://adaptivecards.io/schemas/adaptive-card.json", "version": "1.6", "body": [{ "type": "TextBlock", "text": "${if(title, title, description)}", "wrap": true }] } } } + }, + { + "name": "update-inspection", + "description": "Updates an inspection record — status, findings, recommended actions, property, or inspector assignment.", + "capabilities": { "response_semantics": { "data_path": "$", "properties": {}, "static_template": { "type": "AdaptiveCard", "$schema": "https://adaptivecards.io/schemas/adaptive-card.json", "version": "1.6", "body": [{ "type": "TextBlock", "text": "${if(title, title, description)}", "wrap": true }] } } } + }, + { + "name": "update-purchase-order", + "description": "Updates a purchase order status (e.g. approve, reject, complete).", + "capabilities": { "response_semantics": { "data_path": "$", "properties": {}, "static_template": { "type": "AdaptiveCard", "$schema": "https://adaptivecards.io/schemas/adaptive-card.json", "version": "1.6", "body": [{ "type": "TextBlock", "text": "${if(title, title, description)}", "wrap": true }] } } } + }, + { + "name": "get-claim-summary", + "description": "Returns a text summary for a specific claim with key details. Use claim ID or claim number.", + "capabilities": { "response_semantics": { "data_path": "$", "properties": {}, "static_template": { "type": "AdaptiveCard", "$schema": "https://adaptivecards.io/schemas/adaptive-card.json", "version": "1.6", "body": [{ "type": "TextBlock", "text": "${if(title, title, description)}", "wrap": true }] } } } + }, + { + "name": "create-inspection", + "description": "Creates a new inspection record. Only claimNumber is required. ID is auto-generated, status defaults to 'open'. claimId is optional.", + "capabilities": { "response_semantics": { "data_path": "$", "properties": {}, "static_template": { "type": "AdaptiveCard", "$schema": "https://adaptivecards.io/schemas/adaptive-card.json", "version": "1.6", "body": [{ "type": "TextBlock", "text": "${if(title, title, description)}", "wrap": true }] } } } + }, + { + "name": "list-inspectors", + "description": "Lists all available inspectors with their specializations.", + "capabilities": { "response_semantics": { "data_path": "$", "properties": {}, "static_template": { "type": "AdaptiveCard", "$schema": "https://adaptivecards.io/schemas/adaptive-card.json", "version": "1.6", "body": [{ "type": "TextBlock", "text": "${if(title, title, description)}", "wrap": true }] } } } + } + ], + "runtimes": [ + { + "type": "RemoteMCPServer", + "auth": { "type": "None" }, + "spec": { + "url": "https://zava-insurance-mcp.azurewebsites.net/mcp", + "mcp_tool_description": { + "tools": [ + { + "name": "show-claims-dashboard", + "description": "Displays the Zava Insurance claims dashboard showing all claims with status overview, filters, and summary metrics. Supports filtering by status and/or policy holder name. When the user mentions a person's name, first name, last name, or partial name, always pass it as the policyHolderName parameter. The name filter is case-insensitive and supports partial matches.", + "inputSchema": { "type": "object", "properties": { "status": { "type": "string", "description": "Filter claims by status keyword (e.g. 'Open', 'Approved', 'Pending', 'Denied', 'Closed')" }, "policyHolderName": { "type": "string", "description": "Filter claims by policy holder name. Supports partial, case-insensitive matching." } }, "additionalProperties": false }, + "annotations": { "readOnlyHint": true }, + "execution": { "taskSupport": "forbidden" }, + "_meta": { "ui": { "resourceUri": "ui://zava/claims-dashboard.html" } } + }, + { + "name": "show-claim-detail", + "description": "Displays detailed information about a specific insurance claim including related inspections, purchase orders, and contractor assignments. Use claim ID (e.g. '1', '2') or claim number (e.g. 'CN202504990').", + "inputSchema": { "type": "object", "properties": { "claimId": { "type": "string", "description": "The claim ID or claim number to look up" } }, "required": ["claimId"], "additionalProperties": false }, + "annotations": { "readOnlyHint": true }, + "execution": { "taskSupport": "forbidden" }, + "_meta": { "ui": { "resourceUri": "ui://zava/claim-detail.html" } } + }, + { + "name": "show-contractors", + "description": "Displays the list of contractors available for insurance repair work. Optionally filter by specialty or preferred status.", + "inputSchema": { "type": "object", "properties": { "specialty": { "type": "string", "description": "Filter by contractor specialty (e.g. 'Roofing', 'Water Damage', 'Fire')" }, "preferredOnly": { "type": "boolean", "description": "Show only preferred contractors" } }, "additionalProperties": false }, + "annotations": { "readOnlyHint": true }, + "execution": { "taskSupport": "forbidden" }, + "_meta": { "ui": { "resourceUri": "ui://zava/contractors-list.html" } } + }, + { + "name": "update-claim-status", + "description": "Updates the status of an insurance claim. Use claim ID (e.g. '1', '2').", + "inputSchema": { "type": "object", "properties": { "claimId": { "type": "string", "description": "The claim ID" }, "status": { "type": "string", "description": "New status (e.g. 'Approved', 'Denied', 'Closed', 'Open - Under Investigation')" }, "note": { "type": "string", "description": "Optional note to add to the claim" } }, "required": ["claimId", "status"], "additionalProperties": false }, + "execution": { "taskSupport": "forbidden" } + }, + { + "name": "update-inspection", + "description": "Updates an inspection record — status, findings, recommended actions, property, or inspector assignment.", + "inputSchema": { "type": "object", "properties": { "inspectionId": { "type": "string", "description": "The inspection ID (e.g. 'insp-001')" }, "status": { "type": "string", "description": "New status (e.g. 'completed', 'scheduled', 'in-progress', 'cancelled')" }, "findings": { "type": "string", "description": "Updated findings text" }, "recommendedActions": { "type": "array", "items": { "type": "string" }, "description": "Updated recommended actions" }, "property": { "type": "string", "description": "Updated property address" }, "inspectorId": { "type": "string", "description": "Inspector ID to assign (e.g. 'inspector-003')" } }, "required": ["inspectionId"], "additionalProperties": false }, + "execution": { "taskSupport": "forbidden" } + }, + { + "name": "update-purchase-order", + "description": "Updates a purchase order status (e.g. approve, reject, complete).", + "inputSchema": { "type": "object", "properties": { "purchaseOrderId": { "type": "string", "description": "The purchase order ID (e.g. 'po-001')" }, "status": { "type": "string", "description": "New status (e.g. 'approved', 'rejected', 'completed', 'in-progress')" }, "note": { "type": "string", "description": "Optional note to add" } }, "required": ["purchaseOrderId", "status"], "additionalProperties": false }, + "execution": { "taskSupport": "forbidden" } + }, + { + "name": "get-claim-summary", + "description": "Returns a text summary for a specific claim with key details. Use claim ID or claim number.", + "inputSchema": { "type": "object", "properties": { "claimId": { "type": "string", "description": "Claim ID or claim number" } }, "required": ["claimId"], "additionalProperties": false }, + "execution": { "taskSupport": "forbidden" } + }, + { + "name": "create-inspection", + "description": "Creates a new inspection record. Only claimNumber is required. ID is auto-generated, status defaults to 'open'. claimId is optional.", + "inputSchema": { "type": "object", "properties": { "claimNumber": { "type": "string", "description": "The claim number (e.g. 'CN202504990')" }, "claimId": { "type": "string", "description": "Optional claim ID" }, "taskType": { "type": "string", "description": "Type of inspection: 'initial', 're-inspection', 'final'. Defaults to 'initial'" }, "priority": { "type": "string", "description": "Priority: 'low', 'medium', 'high'. Defaults to 'medium'" }, "status": { "type": "string", "description": "Status. Defaults to 'open'" }, "scheduledDate": { "type": "string", "description": "Scheduled date (ISO string)" }, "inspectorId": { "type": "string", "description": "Inspector ID to assign" }, "property": { "type": "string", "description": "Property address" }, "instructions": { "type": "string", "description": "Inspection instructions" } }, "required": ["claimNumber"], "additionalProperties": false }, + "execution": { "taskSupport": "forbidden" } + }, + { + "name": "list-inspectors", + "description": "Lists all available inspectors with their specializations.", + "inputSchema": { "type": "object", "properties": {} }, + "execution": { "taskSupport": "forbidden" } + } + ] + } + }, + "run_for_functions": [ + "show-claims-dashboard", + "show-claim-detail", + "show-contractors", + "update-claim-status", + "update-inspection", + "update-purchase-order", + "get-claim-summary", + "create-inspection", + "list-inspectors" + ] + } + ] +} +``` + +> **Note how tools with UI widgets** (e.g., `show-claims-dashboard`, `show-claim-detail`, `show-contractors`) include `annotations`, `execution`, AND `_meta` with `resourceUri` — all copied verbatim from the tools/list response. Tools without UI (e.g., `update-claim-status`) still include `execution` when the server returned it, but omit `annotations` and `_meta` since the server didn't provide them. + +Register in `declarative-agent.json`: `{ "actions": [{ "id": "zavaPlugin", "file": "zava-plugin.json" }] }` + +--- + +## Complete Example — Authenticated Server + +For an OAuth-protected MCP server at `https://mcp.example.com/mcp`. See [authentication.md](authentication.md) for the full `oauth/register` template (must be added to both `m365agents.yml` and `m365agents.local.yml`). + +### `appPackage/example-plugin.json` + +```json +{ + "$schema": "https://developer.microsoft.com/json-schemas/copilot/plugin/v2.4/schema.json", + "schema_version": "v2.4", + "name_for_human": "Example Service", + "description_for_human": "Search and browse Example Service content", + "namespace": "example", + "functions": [ + { + "name": "search", + "description": "Search Example Service for content matching a query.", + "capabilities": { "response_semantics": { "data_path": "$.items", "properties": { "title": "$.title", "url": "$.url" }, "static_template": { "type": "AdaptiveCard", "$schema": "https://adaptivecards.io/schemas/adaptive-card.json", "version": "1.6", "body": [{ "type": "TextBlock", "text": "[${title}](${url})", "wrap": true, "maxLines": 2 }] } } } + } + ], + "runtimes": [ + { + "type": "RemoteMCPServer", + "auth": { "type": "OAuthPluginVault", "reference_id": "${{_MCP_AUTH_ID}}" }, + "spec": { + "url": "https://mcp.example.com/mcp", + "mcp_tool_description": { + "tools": [ + { "name": "search", "description": "Search Example Service for content matching a query.", "inputSchema": { "type": "object", "properties": { "query": { "description": "Search query", "type": "string" } }, "required": ["query"] } } + ] + } + }, + "run_for_functions": ["search"] + } + ] +} +``` + +--- + +## Multiple MCP Servers + +You can integrate multiple MCP servers by adding multiple runtimes, each with its own auth type. Each runtime has its own `mcp_tool_description.tools` and `run_for_functions`: + +```json +{ + "functions": [ + { "name": "docs_search", "description": "Search Microsoft docs.", "capabilities": { "response_semantics": { "data_path": "$", "properties": {}, "static_template": { "type": "AdaptiveCard", "$schema": "https://adaptivecards.io/schemas/adaptive-card.json", "version": "1.6", "body": [{ "type": "TextBlock", "text": "${if(title, title, description)}", "wrap": true }] } } } }, + { "name": "search", "description": "Search authenticated service.", "capabilities": { "response_semantics": { "data_path": "$", "properties": {}, "static_template": { "type": "AdaptiveCard", "$schema": "https://adaptivecards.io/schemas/adaptive-card.json", "version": "1.6", "body": [{ "type": "TextBlock", "text": "${if(title, title, description)}", "wrap": true }] } } } } + ], + "runtimes": [ + { + "type": "RemoteMCPServer", + "auth": { "type": "None" }, + "spec": { "url": "https://learn.microsoft.com/api/mcp", "mcp_tool_description": { "tools": [{ "name": "docs_search", "description": "Search Microsoft docs.", "inputSchema": { "type": "object", "properties": { "query": { "type": "string" } }, "required": ["query"] } }] } }, + "run_for_functions": ["docs_search"] + }, + { + "type": "RemoteMCPServer", + "auth": { "type": "OAuthPluginVault", "reference_id": "${{_MCP_AUTH_ID}}" }, + "spec": { "url": "https://mcp.example.com/mcp", "mcp_tool_description": { "tools": [{ "name": "search", "description": "Search authenticated service.", "inputSchema": { "type": "object", "properties": { "query": { "type": "string" } }, "required": ["query"] } }] } }, + "run_for_functions": ["search"] + } + ] +} +``` + +--- + +## Common Issues + +| Issue | Solution | +|---|---| +| Plugin fails to load | Verify `{name}-plugin.json` exists and has correct `mcp_tool_description.tools` array | +| Tools not recognized | Verify function names match exactly between `functions[]` and `mcp_tool_description.tools[]` | +| Runtime errors | Check that `run_for_functions` includes all functions using that runtime | +| OAuth token errors | Re-authenticate with `mcp-remote` — cached tokens may have expired | +| `_MCP_AUTH_ID` empty | Check `oauth/register` step in both `m365agents.yml` and `m365agents.local.yml` and verify credentials | +| "Invalid redirect URI" | Ensure redirect URI in DCR is `https://teams.microsoft.com/api/platform/v1.0/oAuthRedirect` | + +--- + +## Best Practices + +1. **Always discover tools via MCP protocol** — run the full handshake (initialize → notifications/initialized → tools/list) before writing the plugin manifest. **NEVER fabricate tool names or descriptions.** +2. **Full-fidelity tool copying in `mcp_tool_description.tools`** — each tool object must be a verbatim copy of the tools/list output. Copy every property exactly as returned (`inputSchema`, `annotations`, `execution`, `_meta`, `outputSchema`, `title`, and any other field). The MCP protocol evolves — do NOT maintain a hardcoded allowlist of known fields. If the server returns it, the plugin must include it. Never abbreviate, omit, or rename properties. Do NOT duplicate `inputSchema` or other properties in `functions[]`. +3. **Inline tools in `mcp_tool_description.tools`** — do NOT use a separate tools file; embed the tools array directly in the runtime spec +4. **Match function names exactly** — copy tool names directly from the tools/list output +5. **Always add response semantics** — every function must have `capabilities.response_semantics`, even if using the default (empty body) pattern +6. **Include all tools by default** — inline every tool from `tools/list` unless the developer explicitly asks to limit the set; for all included tools always keep the complete tool object with all properties +7. **Logos are optional** — ask the user if they want a custom logo; if not, use the defaults from `npx -y --package @microsoft/m365agentstoolkit-cli atk new`. Logos must be **PNG only** (no JPG, SVG, etc.) diff --git a/plugins/microsoft-365-agents-toolkit/skills/declarative-agent-developer/references/scaffolding-workflow.md b/plugins/microsoft-365-agents-toolkit/skills/declarative-agent-developer/references/scaffolding-workflow.md new file mode 100644 index 0000000..9c42d28 --- /dev/null +++ b/plugins/microsoft-365-agents-toolkit/skills/declarative-agent-developer/references/scaffolding-workflow.md @@ -0,0 +1,193 @@ +# Scaffolding Workflow + +Step-by-step instructions for scaffolding a new M365 Copilot agent project. + +## ⛔ STOP — READ THIS FIRST + +### ATK CLI Setup + +Check if ATK CLI is available by running `npx -y --package @microsoft/m365agentstoolkit-cli atk --version`. If the command is not found, **STOP and tell the user** that the ATK CLI is required but not installed. Do NOT attempt to install it yourself — the user must install ATK separately before you can proceed. + +### The Only Valid Command + +Copy this command EXACTLY. Replace `` with the user's project name: + +```bash +npx -y --package @microsoft/m365agentstoolkit-cli atk new -n -c declarative-agent -with-plugin no -i false +``` + +### Forbidden Commands — These Do Not Exist + +| ❌ Invalid Command | Why It Fails | +|-------------------|--------------| +| `npx -y --package @microsoft/m365agentstoolkit-cli atk init` | DOES NOT EXIST — there is no init command | +| `npx -y --package @microsoft/m365agentstoolkit-cli atk init --template` | DOES NOT EXIST — there is no init or --template flag | +| `npx -y --package @microsoft/m365agentstoolkit-cli atk create` | DOES NOT EXIST — there is no create command | +| `npx -y --package @microsoft/m365agentstoolkit-cli atk scaffold` | DOES NOT EXIST — there is no scaffold command | +| `--template anything` | DOES NOT EXIST — there is no --template flag | + +--- + +## Workflow + +### Step 1: Understand the Request + +**Action:** Verify the user wants to create a NEW M365 Copilot agent project. + +**Check for:** +- Keywords: "new project", "create agent", "scaffold", "start from scratch", "M365 Copilot", "M365 agent", "declarative agent" +- Confirmation this is NOT an existing project + +**If existing project:** Stop and use the editing workflow instead. + +### Step 2: Verify Empty Directory and Collect Project Name + +**Action:** Check if the current directory is empty, then ask for the project name. + +**Directory check (CRITICAL):** +- Use `ls -A` to check if the current directory is empty +- **Ignore hidden folders** (starting with `.`) — these are meta-configuration folders (`.claude`, `.copilot`, `.github`) and should not block scaffolding +- If ONLY hidden folders exist, treat the directory as empty and proceed +- If directory has non-hidden files/folders, **ERROR OUT immediately**: + ``` + ❌ Error: Current directory is not empty! + + This skill requires an empty directory to scaffold a new M365 Copilot agent project. + Please navigate to an empty directory or create a new one first. + ``` +- Do NOT ask for a project name until the directory check passes + +**Project naming rules:** +- Use **kebab-case** (lowercase with hyphens): `customer-support-agent`, `expense-tracker` +- Keep it concise: 2–4 words maximum +- No spaces, underscores, or special characters +- ✅ Good: `sales-dashboard`, `document-finder`, `hr-faq-agent` +- ❌ Bad: `agent1`, `test`, `ExpenseTrackerAgent`, `my project` + +### Step 3: Run ATK CLI Command and Move Files + +**Action:** Execute the scaffolding command, then move files from the ATK-created subfolder to the current directory. + +Always use `-i false` (non-interactive mode) to prevent unexpected prompts. + +**Commands to execute sequentially:** + +1. **Create the project:** +```bash +npx -y --package @microsoft/m365agentstoolkit-cli atk new -n -c declarative-agent -with-plugin no -i false +``` + +2. **Move all files from the subfolder to current directory:** +```bash +mv /* /.* . 2>/dev/null || true +``` + +3. **Delete the now-empty subfolder:** +```bash +rmdir +``` + +4. **Verify success:** +- Check that key files exist in the current directory (`package.json`, `m365agents.yml`) +- Confirm the ATK-created subfolder was removed +- If the command fails, report the error and stop — do NOT retry automatically + +### Step 4: Add Agent Context Files + +**Action:** Ensure the project has context files that tell coding agents which skills are available and how to invoke them. This is critical for future sessions — without these files, agents won't know to use the `declarative-agent-developer` skill. + +**Detection logic — check for existing files in this order:** + +1. `.github/copilot-instructions.md` — if it exists, **edit it** to append the skill context block below +2. `AGENTS.md` — if it exists, **edit it** to add the skill context block below +3. `CLAUDE.md` (that is NOT a symlink) — if it exists, **edit it** to add the skill context block below +4. **If NONE of the above exist** — create both: + - `AGENTS.md` — with the full content below + - `CLAUDE.md` — as a **symlink** to `AGENTS.md` (`ln -sf AGENTS.md CLAUDE.md`) + +**Content to add** (when editing an existing file, append this section; when creating `AGENTS.md`, use this as the full content): + +````markdown +# M365 Declarative Agent Project + +This is an M365 Copilot declarative agent project managed by the ATK CLI. + +## Available Skills + +When working on this project, you MUST use the appropriate skill for the task. **Do NOT work directly on files without invoking a skill first.** + +| Skill | When to Use | +|-------|-------------| +| **declarative-agent-developer** | Any task involving this agent (see scenarios below). **This is the primary skill for this project.** | +| **ui-widget-developer** | Only when adding an MCP server that renders rich interactive widgets (HTML) in Copilot Chat using the OpenAI Apps SDK. | +| **install-atk** | Only when the ATK CLI is not installed or needs updating. | + +## ⛔ MANDATORY: Invoke `declarative-agent-developer` Skill First + +**This is a declarative agent project. For ANY task in this workspace — regardless of what it is — you MUST invoke the `declarative-agent-developer` skill BEFORE doing any work.** This is not optional. Do not attempt to handle any task yourself. Always delegate to the skill. + +### Scenarios handled by this skill + +- Creating a new agent project from scratch +- Editing manifests (declarativeAgent.json, manifest.json, m365agents.yml) +- Adding or removing capabilities (web search, Graph connectors, etc.) +- Adding API plugins from OpenAPI specs +- Adding MCP server plugins +- Adding OAuth authentication to plugins +- Localizing an agent into multiple languages +- Adding a new language to an already-localized agent +- Writing or updating agent instructions +- Deploying and provisioning with `atk provision` +- Validating the project with `atk validate` +- Fixing manifest errors or validation failures + +**Do NOT:** +- Edit `declarativeAgent.json` or other manifest files directly without the skill +- Run `npx -y --package @microsoft/m365agentstoolkit-cli atk` commands without the skill +- "Help" by manually making changes — always delegate to the skill +```` + +**Rules:** +- When editing an existing file, preserve all existing content — append the skill context block at the end +- When creating new files, use the content above as-is +- Always create the `CLAUDE.md` symlink when creating a new `AGENTS.md` — both GitHub Copilot CLI and Claude Code read these files automatically +- This step is NOT optional — every scaffolded project must have agent context files + +### Step 5: Confirm and Continue + +**Action:** Provide a brief confirmation and immediately continue to the editing workflow. + +``` +✅ Project scaffolded in current directory: + +Your M365 Copilot agent project structure is ready (JSON-based). +Agent context files have been added for future skill invocation. + +🚀 Continuing to help you design and implement your agent... +``` + +Then invoke the editing workflow — do NOT wait for user input. + +--- + +## Scope Boundaries + +This workflow **only** handles project creation and agent context setup. After scaffolding: + +- ✅ Confirm creation and hand off to the editing workflow automatically +- ❌ Do NOT discuss architecture, capability selection, or API plugin design +- ❌ Do NOT write JSON manifests, instructions, or configuration +- ❌ Do NOT create TODO files, open VS Code workspaces, or run extra commands +- ❌ Do NOT provide implementation guidance — that's for the editing workflow + +--- + +## Error Handling + +| Error | Action | +|-------|--------| +| ATK CLI not installed | Stop. Tell the user to install ATK first. | +| Directory not empty | Stop. Show error message. Do not proceed. | +| Invalid project name | Warn and suggest a corrected name. | +| `npx -y --package @microsoft/m365agentstoolkit-cli atk new` command fails | Report the error with full output. Do not retry. | +| File move fails | Report the error. Files may still be in the subfolder. | diff --git a/plugins/microsoft-365-agents-toolkit/skills/declarative-agent-developer/references/schema.md b/plugins/microsoft-365-agents-toolkit/skills/declarative-agent-developer/references/schema.md new file mode 100644 index 0000000..1650061 --- /dev/null +++ b/plugins/microsoft-365-agents-toolkit/skills/declarative-agent-developer/references/schema.md @@ -0,0 +1,40 @@ +# JSON Schema Reference for M365 Copilot Agents + +This document provides schema version information, compatibility rules, and links to the official documentation for M365 Copilot declarative agent and API plugin manifests. + +For full property details, examples, and JSON structures, refer to the linked GitHub documentation below. + +## Schema Resources + +### Declarative Agent Manifest Versions + +| Version | JSON Schema | Documentation | +|---------|-------------|---------------| +| **v1.6** | [schema.json](https://developer.microsoft.com/json-schemas/copilot/declarative-agent/v1.6/schema.json) | [declarative-agent-manifest-1.6.md](https://raw.githubusercontent.com/MicrosoftDocs/m365copilot-docs/main/docs/declarative-agent-manifest-1.6.md) | +| v1.5 | [schema.json](https://developer.microsoft.com/json-schemas/copilot/declarative-agent/v1.5/schema.json) | [declarative-agent-manifest-1.5.md](https://raw.githubusercontent.com/MicrosoftDocs/m365copilot-docs/main/docs/declarative-agent-manifest-1.5.md) | +| v1.4 | [schema.json](https://developer.microsoft.com/json-schemas/copilot/declarative-agent/v1.4/schema.json) | [declarative-agent-manifest-1.4.md](https://raw.githubusercontent.com/MicrosoftDocs/m365copilot-docs/main/docs/declarative-agent-manifest-1.4.md) | +| v1.3 | [schema.json](https://developer.microsoft.com/json-schemas/copilot/declarative-agent/v1.3/schema.json) | [declarative-agent-manifest-1.3.md](https://raw.githubusercontent.com/MicrosoftDocs/m365copilot-docs/main/docs/declarative-agent-manifest-1.3.md) | +| v1.2 | [schema.json](https://developer.microsoft.com/json-schemas/copilot/declarative-agent/v1.2/schema.json) | [declarative-agent-manifest-1.2.md](https://raw.githubusercontent.com/MicrosoftDocs/m365copilot-docs/main/docs/declarative-agent-manifest-1.2.md) | +| v1.0 | [schema.json](https://developer.microsoft.com/json-schemas/copilot/declarative-agent/v1.0/schema.json) | [declarative-agent-manifest-1.0.md](https://raw.githubusercontent.com/MicrosoftDocs/m365copilot-docs/main/docs/declarative-agent-manifest-1.0.md) | + +### API Plugin Manifest Versions + +| Version | JSON Schema | Documentation | +|---------|-------------|---------------| +| **v2.4** | [schema.json](https://aka.ms/json-schemas/copilot/plugin/v2.4/schema.json) | [plugin-manifest-2.4.md](https://raw.githubusercontent.com/MicrosoftDocs/m365copilot-docs/main/docs/plugin-manifest-2.4.md) | +| v2.3 | [schema.json](https://aka.ms/json-schemas/copilot/plugin/v2.3/schema.json) | [plugin-manifest-2.3.md](https://raw.githubusercontent.com/MicrosoftDocs/m365copilot-docs/main/docs/plugin-manifest-2.3.md) | +| v2.2 | [schema.json](https://aka.ms/json-schemas/copilot/plugin/v2.2/schema.json) | [plugin-manifest-2.2.md](https://raw.githubusercontent.com/MicrosoftDocs/m365copilot-docs/main/docs/plugin-manifest-2.2.md) | +| v2.1 | [schema.json](https://aka.ms/json-schemas/copilot/plugin/v2.1/schema.json) | [plugin-manifest-2.1.md](https://raw.githubusercontent.com/MicrosoftDocs/m365copilot-docs/main/docs/plugin-manifest-2.1.md) | + +--- + +## How to Use These References + +When building or editing an agent manifest, **fetch the documentation for the version you are using** from the links above. The linked docs contain: + +- Complete property definitions with types, descriptions, and constraints +- JSON examples for every object type (capabilities, actions, runtimes, etc.) +- Capability configuration details (WebSearch sites, OneDriveAndSharePoint items_by_url, Email shared_mailbox, etc.) +- API plugin function and runtime structures +- Response semantics and Adaptive Card templates +- MCP Server (RemoteMCPServer) runtime configuration diff --git a/plugins/microsoft-365-agents-toolkit/skills/declarative-agent-developer/references/workspace-gates.md b/plugins/microsoft-365-agents-toolkit/skills/declarative-agent-developer/references/workspace-gates.md new file mode 100644 index 0000000..afb0869 --- /dev/null +++ b/plugins/microsoft-365-agents-toolkit/skills/declarative-agent-developer/references/workspace-gates.md @@ -0,0 +1,161 @@ +# Workspace Detection & Gate Rules + +This document contains detailed rules for workspace detection, gate scenarios, and error handling behavior. + +--- + +## 🚨 CRITICAL — Read Before Anything Else + +**The #1 eval failure pattern is creating files that should not exist.** These rules are HARD BLOCKS: + +1. **If `declarativeAgent.json` does NOT exist and the user asked to edit/modify/add/deploy → REJECT.** Respond with text only. Do NOT create the file. Do NOT create `appPackage/`. Do NOT look at other directories for examples to copy. +2. **If `declarativeAgent.json` has malformed JSON → DETECT first, then INFORM, then ASK.** You must parse the file and report errors to the user BEFORE making any edits. Never edit a broken file without first telling the user it's broken. +3. **If validation finds errors → NEVER run `npx -y --package @microsoft/m365agentstoolkit-cli atk provision`.** There are zero exceptions. Report errors and ask the user. + +**The "Detect → Inform → Ask" protocol is mandatory for ALL error states:** +- **Detect**: Identify the problem (missing file, parse error, validation error) +- **Inform**: Tell the user what you found BEFORE taking action +- **Ask**: Wait for instructions before modifying anything + +--- + +## Gate Definitions + +### Gate 1: Wrong Project Type — STOP + +The workspace contains a non-agent project (Express, React, Django, plain Node.js, Python, Go, etc.) with NO `appPackage/` folder and NO `m365agents.yml`. + +**Your entire response must be text-only. No edits, no commands, no files.** + +**Example rejection:** +``` +This workspace contains an Express.js application (I can see package.json with +express as a dependency, src/index.js, etc.) but it is NOT an M365 declarative +agent project — there is no `appPackage/` folder and no `m365agents.yml`. + +I cannot create agent files inside an existing non-agent project. To create a +declarative agent: +1. Navigate to a separate, empty directory +2. Ask me to create a new agent from scratch +``` + +### Gate 2: No Manifest, User Wants to Edit/Deploy — STOP + +No `appPackage/declarativeAgent.json` exists but user implies an existing agent ("add X to this agent", "deploy this", "update instructions"). + +**Your entire response must be text-only. No edits, no commands, no files.** + +**Anti-patterns that WILL cause eval failure:** +- ❌ Creating `declarativeAgent.json` from scratch to "help" the user +- ❌ Creating the `appPackage/` directory +- ❌ Looking at other directories/fixtures for examples and copying them +- ❌ Running `npx -y --package @microsoft/m365agentstoolkit-cli atk new` when the user asked to edit (editing ≠ scaffolding) +- ❌ Running ANY `npx -y --package @microsoft/m365agentstoolkit-cli atk` command — the project is not an agent project + +**Example rejection:** +``` +This workspace does not appear to be an M365 declarative agent project — +I could not find `appPackage/declarativeAgent.json`. + +To get started with a new agent, please: +1. Navigate to an empty directory +2. Ask me to create a new agent from scratch + +I cannot add capabilities or plugins to a project that doesn't have an agent manifest. +``` + +### Gate 3: No Manifest, User Wants New Project — Scaffold + +User explicitly says "create a new agent", "scaffold", "start from scratch". The workspace should be empty — if it has a different project, go to Gate 1 instead. + +→ Proceed to [Scaffolding Workflow](scaffolding-workflow.md) + +### Gate 4: Manifest Exists with Errors — Fix First + +`declarativeAgent.json` exists but has validation errors. + +**Rules:** +- Parse and check `declarativeAgent.json` against the expected schema +- Report ALL errors to the user with specific details +- ASK the user before making changes +- **Do NOT** run `npx -y --package @microsoft/m365agentstoolkit-cli atk provision` — fix errors first, no exceptions +- **Do NOT** silently rewrite the entire file — surgical fixes only +- **Do NOT** invent placeholder values for missing required fields + +**Special case — mostly empty manifest** (has `$schema` and `version` but no `name`, `description`, or `instructions`): This is Gate 4. Report missing fields, ASK the user. Do NOT invent values. + +**Malformed JSON handling — STRICT ORDER (do NOT skip steps or reorder):** +1. **DETECT**: Read the file and attempt to parse it. Identify all syntax errors. +2. **INFORM**: Tell the user the file has malformed JSON BEFORE making any edits. List every syntax issue you found (missing commas, unclosed brackets, trailing commas, etc.) with line numbers. +3. **ASK**: Ask the user if you should fix the syntax errors. Wait for their response. +4. **FIX** (only after user confirms): Fix with surgical edits (not a rewrite — if you're changing >20% of lines, stop and reconsider) +5. **VALIDATE**: Check the manifest against the schema after fixing +6. **DO NOT DEPLOY**: Even after fixing, do NOT run `npx -y --package @microsoft/m365agentstoolkit-cli atk provision` until the user's original request is also addressed and validation passes cleanly + +**⛔ Malformed JSON anti-patterns that WILL cause eval failure:** +- ❌ Reading the file and immediately editing it without telling the user it's broken +- ❌ Fixing JSON errors as part of a larger edit (fix syntax → inform → ask, THEN handle the user's request separately) +- ❌ Running `npx -y --package @microsoft/m365agentstoolkit-cli atk provision` after fixing syntax errors +- ❌ Validating AFTER editing instead of detecting errors BEFORE editing +- ❌ Mentioning malformed JSON only in a summary at the end instead of upfront + +### Gate 5: Valid Project, User Reports Behavior Issues — Review + +`declarativeAgent.json` exists and is valid, but the user reports that the agent "doesn't work well", "gives generic answers", "doesn't use the right tool", "ignores capabilities", or shows behavior changes after a model update. + +**Trigger phrases:** "review instructions", "improve instructions", "my agent doesn't work", "agent gives generic answers", "agent doesn't follow the process", "agent changed after update" + +→ Proceed to [Instruction Review](instruction-review.md) — run the full 5-phase review workflow (Inventory → Comprehension Check → Diagnose → Report → Rewrite). + +**Rules:** +- Follow the Detect → Inform → Ask protocol — diagnose first, present findings, wait for approval before rewriting +- Do NOT skip directly to editing the instructions — run the diagnostic checklist first +- Do NOT deploy until the review is complete and the user has approved changes + +### Gate 6: Valid Agent Project — Edit + +→ Proceed to [Editing Workflow](editing-workflow.md) + +--- + +## STOP Scenarios Quick Reference + +| Scenario | What you see | What you MUST do | What you MUST NOT do | +|----------|-------------|-----------------|---------------------| +| Express/React/Node app | `package.json` + `src/index.js` but NO `appPackage/` | Text-only: tell user this is NOT an agent project | ❌ Create `appPackage/` ❌ Run `npx -y --package @microsoft/m365agentstoolkit-cli atk new` ❌ Create ANY files | +| No manifest, edit request | No `declarativeAgent.json`, user says "add capability" | Text-only: explain manifest is missing | ❌ Create files ❌ Scaffold ❌ "Help" by creating missing files | +| Manifest missing fields | `declarativeAgent.json` missing `name`/`description`/`instructions` | List ALL missing fields, ASK user | ❌ Invent placeholders ❌ Auto-fill ❌ Run `npx -y --package @microsoft/m365agentstoolkit-cli atk provision` | +| Manifest has errors | Manifest has structural/schema errors | Report ALL errors, suggest fixes, ask user | ❌ Silently fix ❌ Deploy ❌ Auto-correct | +| Valid project, behavior issues | Valid manifest, user says "agent doesn't work well" | Run Instruction Review workflow (5 phases) | ❌ Jump to editing without diagnosis ❌ Deploy without review ❌ Rewrite without user approval | + +--- + +## Anti-Patterns (Gate 4) + +These will cause eval failure: + +**File creation violations:** +- ❌ Creating `declarativeAgent.json` when it doesn't exist (this is Gate 2, not Gate 4) +- ❌ Scaffolding a new project to "fix" an incomplete manifest +- ❌ Creating `appPackage/` directory in a non-agent project + +**Content invention violations:** +- ❌ Inventing placeholder values (generic names, boilerplate instructions) +- ❌ Auto-completing missing fields without asking + +**Malformed JSON violations:** +- ❌ Editing a malformed file without first telling the user it's broken +- ❌ Combining syntax fixes with other edits in one step (fix syntax first, THEN handle the request) +- ❌ Validating JSON only AFTER editing — you must detect errors BEFORE editing +- ❌ Mentioning "the file had malformed JSON" only in a final summary + +**Deployment violations:** +- ❌ Running `npx -y --package @microsoft/m365agentstoolkit-cli atk provision` when validation found errors — not even "to test" +- ❌ Running `npx -y --package @microsoft/m365agentstoolkit-cli atk provision` "to see what happens" +- ❌ Auto-correcting errors and deploying without asking +- ❌ Deploying "for educational purposes" to show error output + +**"EVEN IF..." — No exceptions to the deploy block:** +- EVEN IF deploying would "demonstrate the error" — just report errors +- EVEN IF the user says "deploy this" — if you know there are errors, explain why you can't +- EVEN IF you fixed errors yourself — verify the JSON is valid before deploying diff --git a/plugins/microsoft-365-agents-toolkit/skills/install-atk/SKILL.md b/plugins/microsoft-365-agents-toolkit/skills/install-atk/SKILL.md new file mode 100644 index 0000000..44afbcb --- /dev/null +++ b/plugins/microsoft-365-agents-toolkit/skills/install-atk/SKILL.md @@ -0,0 +1,70 @@ +--- +name: install-atk +description: > + Install or update the M365 Agents Toolkit (ATK) CLI and VS Code extension. + Triggers: "install atk", "update atk", "install agents toolkit", "update agents toolkit", + "install the toolkit", "setup atk", "get atk", "install atk cli", "install atk extension", + "install atk vsix", "update the vs code extension", "install latest atk", "upgrade atk" +--- + +# Install ATK + +Install or update the M365 Agents Toolkit (ATK) CLI and/or VS Code extension. + +## Telemetry Tagging + +Before running any `atk` CLI commands, set the session environment variable so all CLI invocations are tagged as skill-initiated: +```bash +export ATK_CLI_SKILL=true +``` +Run this once at the start of the session. All subsequent `atk` commands in the same terminal will inherit it. + +## Triggers + +This skill activates when the user asks to: +- Install or update ATK / Agents Toolkit / the toolkit +- Install or update the ATK CLI +- Install or update the ATK VS Code extension / VSIX +- Set up ATK / get started with ATK + +## Behavior + +ATK CLI commands use `npx -y --package @microsoft/m365agentstoolkit-cli atk` which automatically downloads and runs the latest version — no global installation is needed. + +When triggered, determine what the user wants: + +| User intent | Action | +|-------------|--------| +| Install/update **everything** or just "ATK" | Verify CLI works + install VSIX | +| Install/update **CLI** only | Verify CLI works (npx handles it automatically) | +| Install/update **extension** / **VSIX** only | Install VSIX only | +| Ambiguous | Verify CLI works + install VSIX | + +## Commands + +### Step 1: Verify ATK CLI works + +```bash +npx -y --package @microsoft/m365agentstoolkit-cli atk --version +``` + +- **If this succeeds** (prints a version): ATK CLI is available. The `npx` prefix automatically downloads the latest package on first use and caches it for subsequent runs. +- **If this fails**: Check that Node.js 18+ and npm are installed. The `npx` command requires a working Node.js environment. + +### Step 2: ATK VS Code Extension (if requested) + +```bash +code --install-extension TeamsDevApp.ms-teams-vscode-extension +``` + +## Execution + +1. **Verify** ATK CLI works by running `npx -y --package @microsoft/m365agentstoolkit-cli atk --version` +2. Report the result (version number / failure) +3. Install VS Code extension if requested +4. Explain that all ATK commands use the `npx -y --package @microsoft/m365agentstoolkit-cli atk` prefix — no global install needed + +## Safety Rules + +- **MUST NOT** skip errors — report failures clearly to the user +- **MUST** use the exact package names and extension IDs above — do not substitute with other names or links diff --git a/plugins/microsoft-365-agents-toolkit/skills/m365-agent-evaluator/SKILL.md b/plugins/microsoft-365-agents-toolkit/skills/m365-agent-evaluator/SKILL.md new file mode 100644 index 0000000..f09feb2 --- /dev/null +++ b/plugins/microsoft-365-agents-toolkit/skills/m365-agent-evaluator/SKILL.md @@ -0,0 +1,158 @@ +--- +name: m365-agent-evaluator +description: > + Use this skill when a user wants to create, run, or analyze evaluation suites for Microsoft 365 Copilot declarative agents with the public @microsoft/m365-copilot-eval CLI. Trigger on intents such as "evaluate my agent", "test my agent", "run my evals", "create eval prompts", "add multi-turn tests", "tune evaluator thresholds", "why is my agent failing", or "set up eval environment variables". +--- + +# M365 Agent Evaluator + +Use this skill to help users evaluate Microsoft 365 Copilot declarative agents with `@microsoft/m365-copilot-eval`. The skill designs schema-compatible eval datasets, runs the public preview CLI, analyzes results, and recommends targeted fixes. + +Default to Microsoft 365 Agents Toolkit (ATK) projects when detected, but do not hard-stop solely because the current directory is not ATK. The CLI can also evaluate deployed agents with an explicit `M365_AGENT_ID` or `--m365-agent-id`. + +## Always use this CLI invocation + +```powershell +npx -y --package @microsoft/m365-copilot-eval@latest runevals +``` + +Do not recommend the old private `aka.ms` installer, global installs, bare `runevals`, bare `npx runevals`, `--input`, or `--html`. + +## Activation workflow + +1. Identify the user goal: setup, dataset authoring, running evals, analyzing results, or updating an existing eval suite. +2. Load only the reference needed for the current goal: + - `references/workflow.md` for the end-to-end operator workflow and CLI commands. + - `references/azure-setup.md` for prerequisites, env files, and secret handling. + - `references/eval-templates.md` when creating or editing eval datasets. + - `references/pra-framework.md` when deciding what scenarios to generate. + - `references/result-analysis.md` after JSON/CSV/HTML results exist. + - `references/guardrails.md` before writing files, handling secrets, clearing cache, signing out, or troubleshooting. +3. Detect project shape: + - ATK: `.env.local`, `.env.local.user`, `env\.env.local.user`, `m365agents.yml`, or `appPackage\declarativeAgent.json`. + - Non-ATK: an eval dataset plus `M365_AGENT_ID`, `--m365-agent-id`, or a named environment file such as `env\.env.dev`. +4. Verify prerequisites without exposing values: + - Node.js 24.12.0 or newer. + - Microsoft 365 Copilot license and a deployed M365 Copilot agent. + - Tenant admin consent for the WorkIQ Client App. + - `TENANT_ID`, Azure OpenAI in Foundry Models endpoint/key, and recommended/default `gpt-4o-mini` deployment. +5. Choose the workflow: + - No dataset: create `evals\evals.json`. + - Existing dataset: run, analyze prior results, or propose changes. + - Quick check: use inline prompts. + - Exploration: use interactive mode. + +## Current dataset contract + +Generate schema version `1.2.0` documents with a root `items` array. Do not generate the old `PromptsObject` or root `prompts` format. + +Minimum shape: + +```json +{ + "schemaVersion": "1.2.0", + "metadata": { + "name": "Agent evaluation suite", + "tags": ["starter"] + }, + "default_evaluators": { + "Relevance": {}, + "Coherence": {} + }, + "items": [ + { + "prompt": "What can this agent help me with?", + "expected_response": "The agent explains its supported scope without inventing unsupported capabilities." + } + ] +} +``` + +Use `references\prompts-schema.json` as the local schema source and `references\eval-templates.md` for copyable single-turn, multi-turn, evaluator, and threshold examples. + +## Public evaluator names + +Evaluator names are case-sensitive. Use only the public configurable evaluator names unless a newer authoritative source proves otherwise. + +| Evaluator | Semantics | +|---|---| +| `Relevance` | LLM score from 1-5; default threshold 3. | +| `Coherence` | LLM score from 1-5; default threshold 3. | +| `Groundedness` | LLM score from 1-5 against `context`/expected evidence; default threshold 3. | +| `Similarity` | LLM score from 1-5 against `expected_response`; default threshold 3. | +| `Citations` | Count-based citation check; default threshold 1. | +| `ExactMatch` | Boolean exact string match. | +| `PartialMatch` | String similarity from 0.0-1.0; default threshold 0.5. | + +Treat `ToolCallAccuracy` as legacy/private for authoring. Do not add it to generated datasets unless current public CLI/schema documentation explicitly reintroduces it. + +## Common commands + +```powershell +# Version/help checks +npx -y --package @microsoft/m365-copilot-eval@latest runevals --version +npx -y --package @microsoft/m365-copilot-eval@latest runevals --help + +# First-time setup / EULA +npx -y --package @microsoft/m365-copilot-eval@latest runevals accept-eula +npx -y --package @microsoft/m365-copilot-eval@latest runevals --init-only + +# Batch run with explicit JSON output +npx -y --package @microsoft/m365-copilot-eval@latest runevals --prompts-file evals\evals.json --output .evals\results.json + +# Human-review HTML or spreadsheet-friendly CSV +npx -y --package @microsoft/m365-copilot-eval@latest runevals --prompts-file evals\evals.json --output .evals\results.html +npx -y --package @microsoft/m365-copilot-eval@latest runevals --prompts-file evals\evals.json --output .evals\results.csv + +# Quick checks +npx -y --package @microsoft/m365-copilot-eval@latest runevals --prompts "What can you help me with?" --expected "The agent describes its supported scope." + +# Non-ATK or named environment +npx -y --package @microsoft/m365-copilot-eval@latest runevals --prompts-file evals\evals.json --m365-agent-id --env dev +``` + +Use `--concurrency` only with values 1-5. Start with `1` for debugging and increase only after setup is stable. + +## Version and PATH safety + +Before diagnosing agent behavior, confirm which executable is running: + +```powershell +Get-Command runevals -All +npm list -g @microsoft/m365-copilot-eval --depth=0 +npm view @microsoft/m365-copilot-eval version +npx -y --package @microsoft/m365-copilot-eval@latest runevals --version +npx -y --package @microsoft/m365-copilot-eval@latest where runevals +``` + +If bare `runevals` prints `This version of the M365 Evals CLI has stopped working and must be updated`, treat it as a stale PATH/global install. Re-run with the `npx --package ...@latest` command above, then ask before removing global shims with `npm uninstall -g @microsoft/m365-copilot-eval`. + +## File conventions + +| Path | Purpose | +|---|---| +| `.env.local` | Non-secret ATK config such as `M365_TITLE_ID`. | +| `.env.local.user` or `env\.env.local.user` | Local secrets such as tenant ID and Azure OpenAI key. | +| `env\.env.` | Named environment config for non-ATK or explicit `--env` workflows. | +| `evals\evals.json` | Source-controlled eval dataset if the user wants it committed. | +| `.evals\` | Local run outputs; usually gitignored. | + +Never print or commit secrets, prompts containing sensitive data, retrieved content, debug logs, or raw result files unless the user explicitly asks and confirms the data is safe to share. + +## Generation guidance + +Use PRA as a scenario-design framework: + +- Perceive: retrieval, grounding, and source coverage. +- Reason: instruction adherence, synthesis, ambiguity handling, and refusal behavior. +- Act: declared capability/action behavior. Score with public evaluators such as `Relevance`, `Coherence`, `Similarity`, `ExactMatch`, or `PartialMatch`; do not use legacy `ToolCallAccuracy`. + +Ask before overwriting an existing dataset. When writing generated evals, write to a temporary file first and rename on success. + +## Result analysis guidance + +Analyze only evaluator keys that are present. Missing score keys usually mean the evaluator was not configured for that item, not that it failed. + +Use current score keys when present: `relevance`, `coherence`, `groundedness`, `similarity`, `citations`, `exactMatch`, and `partialMatch`. Group failures into likely root causes: instruction issue, grounding issue, citation issue, expected-answer mismatch, capability gap, auth/environment issue, or eval-quality issue. + +Do not run real tenant-dependent evals unless the user has provided or approved the necessary tenant, agent, and Azure OpenAI configuration. diff --git a/plugins/microsoft-365-agents-toolkit/skills/m365-agent-evaluator/examples/analyze-failures.md b/plugins/microsoft-365-agents-toolkit/skills/m365-agent-evaluator/examples/analyze-failures.md new file mode 100644 index 0000000..3a6c51b --- /dev/null +++ b/plugins/microsoft-365-agents-toolkit/skills/m365-agent-evaluator/examples/analyze-failures.md @@ -0,0 +1,39 @@ +# Example: analyze existing failures + +User intent: "Here is my eval output. What should I fix?" + +## Inputs + +Expected files: + +```text +.evals\latest.json +evals\evals.json +``` + +Do not paste raw prompts, responses, retrieved data, or logs into chat if they may contain sensitive content. + +## Process + +1. Load `references\result-analysis.md` and `references\remediation-patterns.md`. +2. Inspect `metadata` for CLI version, agent ID/name, and evaluated time. +3. Inspect each item under `items`. +4. For each item, read sparse `scores` keys such as `relevance`, `coherence`, `groundedness`, `similarity`, `citations`, `exactMatch`, and `partialMatch`. +5. Treat missing score keys as "not configured", not failed. +6. Summarize the smallest targeted changes. + +## Example finding + +```text +Primary issue: Citation behavior is inconsistent. +Evidence: The citation evaluator failed on prompts that ask for workplace-source summaries, while relevance and coherence passed. +Recommended change: Add an instruction requiring citations for source-backed summaries and verify the agent path can surface citations. +Expected effect: `citations` should meet the minimum count threshold without changing the response content. +``` + +## Common false positives + +- Expected response is too specific for a legitimately variable answer. +- `ExactMatch` is used for natural language text. +- A prompt assumes source data that is not available to the deployed agent. +- The run failed during auth, schema validation, or evaluator-model setup. diff --git a/plugins/microsoft-365-agents-toolkit/skills/m365-agent-evaluator/examples/basic-generation.md b/plugins/microsoft-365-agents-toolkit/skills/m365-agent-evaluator/examples/basic-generation.md new file mode 100644 index 0000000..c6f96ff --- /dev/null +++ b/plugins/microsoft-365-agents-toolkit/skills/m365-agent-evaluator/examples/basic-generation.md @@ -0,0 +1,61 @@ +# Example: create a starter eval dataset + +User intent: "Create eval prompts for my M365 Copilot agent." + +## Steps + +1. Confirm the target agent scenario and whether the repo already has `evals\evals.json`. +2. Load `references\eval-templates.md` and `references\pra-framework.md`. +3. Create a schema `1.2.0` dataset with root `items`. +4. Save it under `evals\evals.json` unless the user asks for another path. + +## Starter file + +```json +{ + "schemaVersion": "1.2.0", + "metadata": { + "name": "Starter M365 Copilot agent evals", + "description": "Core smoke tests for agent scope, grounding, and response quality.", + "tags": ["starter", "regression"] + }, + "default_evaluators": { + "Relevance": {}, + "Coherence": {} + }, + "items": [ + { + "prompt": "What can this agent help me with?", + "expected_response": "The agent explains its supported scope without claiming unsupported capabilities." + }, + { + "prompt": "Summarize the latest status for the project using available sources.", + "expected_response": "The agent summarizes available status, distinguishes known facts from missing data, and avoids unsupported claims.", + "evaluators": { + "Groundedness": { + "threshold": 3 + } + }, + "evaluators_mode": "extend" + }, + { + "prompt": "List the open action items with owners.", + "expected_response": "The agent lists action items and owners only when source data supports them.", + "evaluators": { + "Citations": { + "threshold": 1 + } + }, + "evaluators_mode": "extend" + } + ] +} +``` + +## First safe command + +```powershell +npx -y --package @microsoft/m365-copilot-eval@latest runevals --init-only +``` + +Run real evals only after the user confirms tenant, agent, and Azure OpenAI configuration is ready. diff --git a/plugins/microsoft-365-agents-toolkit/skills/m365-agent-evaluator/examples/iterate-on-changes.md b/plugins/microsoft-365-agents-toolkit/skills/m365-agent-evaluator/examples/iterate-on-changes.md new file mode 100644 index 0000000..0a54c57 --- /dev/null +++ b/plugins/microsoft-365-agents-toolkit/skills/m365-agent-evaluator/examples/iterate-on-changes.md @@ -0,0 +1,30 @@ +# Example: iterate after agent changes + +User intent: "I changed my agent instructions. Re-run the evals and compare." + +## Baseline run + +```powershell +npx -y --package @microsoft/m365-copilot-eval@latest runevals --prompts-file evals\evals.json --concurrency 1 --output .evals\baseline.json +``` + +## After-change run + +```powershell +npx -y --package @microsoft/m365-copilot-eval@latest runevals --prompts-file evals\evals.json --concurrency 1 --output .evals\after-instructions.json +``` + +Keep the dataset, evaluator thresholds, model deployment, and concurrency stable when comparing. If the user intentionally changed the dataset, report that the comparison is not a strict regression comparison. + +## Compare + +1. Compare `items` by prompt or conversation name. +2. Compare only score keys that exist in both runs. +3. Look for improvements and regressions by evaluator theme. +4. If a setup/auth/model error appears in only one run, do not call it an agent regression. + +## Example summary + +```text +The instruction change improved grounding on the project-status prompt from fail to pass, but the action-item prompt still fails citations. The next targeted change should require source citations when listing owners, or the eval should be relaxed if the agent cannot expose citations for that data path. +``` diff --git a/plugins/microsoft-365-agents-toolkit/skills/m365-agent-evaluator/examples/missing-instructions.md b/plugins/microsoft-365-agents-toolkit/skills/m365-agent-evaluator/examples/missing-instructions.md new file mode 100644 index 0000000..3263908 --- /dev/null +++ b/plugins/microsoft-365-agents-toolkit/skills/m365-agent-evaluator/examples/missing-instructions.md @@ -0,0 +1,55 @@ +# Example: missing or weak instructions + +User intent: "My agent gives vague answers in evals." + +## Symptoms + +- `relevance` passes but `coherence` fails. +- `groundedness` fails because the agent invents missing facts. +- `similarity` fails because the answer omits required structure or decisions. +- Follow-up turns fail after a successful first turn. + +## Diagnosis + +Check whether the agent instructions specify: + +1. supported scenarios and boundaries, +2. source-grounding expectations, +3. citation expectations, +4. response format, +5. behavior when data is missing, +6. follow-up context handling. + +## Suggested instruction additions + +```text +Use only available workplace sources when answering source-backed questions. If the sources do not contain enough evidence, say what is missing instead of guessing. +``` + +```text +For project-status answers, use this structure: Summary, Evidence, Risks, Next actions. Keep the answer concise and include citations when available. +``` + +```text +For follow-up questions, preserve the project, customer, and time window from the prior turn unless the user changes them. +``` + +## Matching eval update + +Add or keep regression prompts that test the new instruction: + +```json +{ + "prompt": "Summarize the latest status for the project and include citations.", + "expected_response": "The agent summarizes only source-backed status, cites available sources, and states when evidence is missing.", + "evaluators": { + "Groundedness": { + "threshold": 4 + }, + "Citations": { + "threshold": 1 + } + }, + "evaluators_mode": "extend" +} +``` diff --git a/plugins/microsoft-365-agents-toolkit/skills/m365-agent-evaluator/examples/not-atk-project.md b/plugins/microsoft-365-agents-toolkit/skills/m365-agent-evaluator/examples/not-atk-project.md new file mode 100644 index 0000000..2a5d3a4 --- /dev/null +++ b/plugins/microsoft-365-agents-toolkit/skills/m365-agent-evaluator/examples/not-atk-project.md @@ -0,0 +1,62 @@ +# Example: evaluate a non-ATK project + +User intent: "I do not have an Agents Toolkit project. Can I still evaluate a deployed agent?" + +Yes. Use an explicit deployed agent ID through `M365_AGENT_ID` or `--m365-agent-id`. + +## Suggested layout + +```text +evals\evals.json +env\.env.dev +.evals\ +``` + +Example `env\.env.dev` values: + +```text +TENANT_ID= +M365_AGENT_ID= +AZURE_AI_OPENAI_ENDPOINT= +AZURE_AI_API_KEY= +AZURE_AI_API_VERSION=2024-12-01-preview +AZURE_AI_MODEL_NAME=gpt-4o-mini +``` + +Do not print or commit this file if it contains secrets. + +## Commands + +```powershell +npx -y --package @microsoft/m365-copilot-eval@latest runevals --init-only --env dev +``` + +```powershell +npx -y --package @microsoft/m365-copilot-eval@latest runevals --prompts-file evals\evals.json --env dev --output .evals\non-atk.json +``` + +Explicit override: + +```powershell +npx -y --package @microsoft/m365-copilot-eval@latest runevals --prompts-file evals\evals.json --m365-agent-id --env dev --output .evals\non-atk.json +``` + +## Minimal dataset + +```json +{ + "schemaVersion": "1.2.0", + "default_evaluators": { + "Relevance": {}, + "Coherence": {} + }, + "items": [ + { + "prompt": "What can this agent help me with?", + "expected_response": "The agent describes its supported scope." + } + ] +} +``` + +If auth, tenant consent, or model setup fails, resolve that before evaluating agent quality. diff --git a/plugins/microsoft-365-agents-toolkit/skills/m365-agent-evaluator/examples/run-and-analyze.md b/plugins/microsoft-365-agents-toolkit/skills/m365-agent-evaluator/examples/run-and-analyze.md new file mode 100644 index 0000000..35ec0d4 --- /dev/null +++ b/plugins/microsoft-365-agents-toolkit/skills/m365-agent-evaluator/examples/run-and-analyze.md @@ -0,0 +1,46 @@ +# Example: run evals and analyze results + +User intent: "Run my evals and tell me why the agent is failing." + +## Safe preflight + +```powershell +node --version +npx -y --package @microsoft/m365-copilot-eval@latest runevals --version +npx -y --package @microsoft/m365-copilot-eval@latest runevals --help +``` + +Confirm env files exist without printing values. For first-time setup: + +```powershell +npx -y --package @microsoft/m365-copilot-eval@latest runevals accept-eula +npx -y --package @microsoft/m365-copilot-eval@latest runevals --init-only +``` + +## Run with JSON output + +```powershell +npx -y --package @microsoft/m365-copilot-eval@latest runevals --prompts-file evals\evals.json --concurrency 1 --output .evals\latest.json +``` + +Use `--concurrency 1` for debugging. Increase up to `5` only after setup is stable. + +## Optional human report + +```powershell +npx -y --package @microsoft/m365-copilot-eval@latest runevals --prompts-file evals\evals.json --output .evals\latest.html +``` + +## Analysis approach + +1. Load `references\result-analysis.md`. +2. Parse `items` from the JSON output. +3. Check only score keys that exist. +4. Separate setup/auth/model/schema failures from quality failures. +5. Group quality failures by likely fix: instructions, grounding, citations, expected response, or capability gap. + +Example response: + +```text +The main issue is grounding: two prompts passed relevance/coherence but failed groundedness. The agent answered with plausible project facts that were not present in the provided sources. Recommended change: add an instruction to answer only from retrieved workplace sources and say what is missing when evidence is insufficient. +``` diff --git a/plugins/microsoft-365-agents-toolkit/skills/m365-agent-evaluator/references/azure-setup.md b/plugins/microsoft-365-agents-toolkit/skills/m365-agent-evaluator/references/azure-setup.md new file mode 100644 index 0000000..e7a6ca1 --- /dev/null +++ b/plugins/microsoft-365-agents-toolkit/skills/m365-agent-evaluator/references/azure-setup.md @@ -0,0 +1,142 @@ +# Environment and Azure setup + +Use this reference when the user needs prerequisites, env files, admin consent, authentication, or Azure OpenAI configuration for `@microsoft/m365-copilot-eval`. + +## Prerequisites + +| Requirement | Notes | +|---|---| +| Node.js | Node.js 24.12.0 or newer. | +| Operating system | Public docs describe authentication as Windows-first. If another OS fails during auth, validate on Windows before diagnosing the agent. | +| Microsoft 365 Copilot | The signed-in user needs a Microsoft 365 Copilot license. | +| Deployed agent | Evaluate a deployed Microsoft 365 Copilot declarative agent, not only a local manifest. | +| Tenant admin consent | Tenant admin consent is required for the WorkIQ Client App before first use. | +| Azure OpenAI in Foundry Models | The evaluator model endpoint and key are required for LLM-based metrics. | + +## Recommended CLI checks + +```powershell +node --version +npx -y --package @microsoft/m365-copilot-eval@latest runevals --version +npx -y --package @microsoft/m365-copilot-eval@latest runevals --help +npx -y --package @microsoft/m365-copilot-eval@latest runevals accept-eula +npx -y --package @microsoft/m365-copilot-eval@latest runevals --init-only +``` + +Do not print environment variable values while checking setup. + +## Version and PATH checks + +The public preview CLI can retire older versions. Check both the package-scoped version and any bare `runevals` shim before troubleshooting: + +```powershell +Get-Command runevals -All +npm list -g @microsoft/m365-copilot-eval --depth=0 +npm view @microsoft/m365-copilot-eval version +npx -y --package @microsoft/m365-copilot-eval@latest runevals --version +npx -y --package @microsoft/m365-copilot-eval@latest where runevals +``` + +If `runevals` without `npx --package` fails with `This version of the M365 Evals CLI has stopped working and must be updated`, a stale global install is being resolved from PATH. Continue with the package-scoped `@latest` command and ask before removing global installs. + +## Required configuration values + +| Variable | Required | Secret | Purpose | +|---|---:|---:|---| +| `TENANT_ID` | Yes | No | Microsoft Entra tenant ID for the evaluation run. | +| `AZURE_AI_OPENAI_ENDPOINT` | Yes | No | Azure OpenAI in Foundry Models endpoint. | +| `AZURE_AI_API_KEY` | Yes | Yes | Key used by the evaluator model client. | +| `M365_TITLE_ID` | ATK path | No | Agents Toolkit title ID auto-detected from `.env.local`. | +| `M365_AGENT_ID` | Non-ATK or override | No | Deployed M365 Copilot agent ID. | +| `AZURE_AI_API_VERSION` | No | No | Defaults to `2024-12-01-preview`. | +| `AZURE_AI_MODEL_NAME` | No | No | Defaults/recommended value: `gpt-4o-mini`. | + +## File placement + +### Agents Toolkit project + +Use `.env.local` for non-secret project configuration: + +```text +M365_TITLE_ID= +AZURE_AI_API_VERSION=2024-12-01-preview +AZURE_AI_MODEL_NAME=gpt-4o-mini +``` + +Use `.env.local.user` or `env\.env.local.user` for local secrets: + +```text +TENANT_ID= +AZURE_AI_OPENAI_ENDPOINT= +AZURE_AI_API_KEY= +``` + +### Non-ATK or named environment + +Use a named environment file and select it with `--env`: + +```text +env\.env.dev +``` + +Example: + +```text +TENANT_ID= +M365_AGENT_ID= +AZURE_AI_OPENAI_ENDPOINT= +AZURE_AI_API_KEY= +AZURE_AI_API_VERSION=2024-12-01-preview +AZURE_AI_MODEL_NAME=gpt-4o-mini +``` + +Run with: + +```powershell +npx -y --package @microsoft/m365-copilot-eval@latest runevals --prompts-file evals\evals.json --env dev +``` + +Or override the agent ID directly: + +```powershell +npx -y --package @microsoft/m365-copilot-eval@latest runevals --prompts-file evals\evals.json --m365-agent-id --env dev +``` + +## Environment precedence + +When diagnosing surprising values, check likely sources in this order: + +1. Project local files such as `.env.local`. +2. Local user secret files such as `.env.local.user` or `env\.env.local.user`. +3. Named environment files such as `env\.env.dev` when selected with `--env dev`. +4. System environment variables. + +Live CLI help has shown `--env` defaulting to `local`; some docs have used `dev`. Prefer passing `--env ` explicitly when relying on a named file. + +## Gitignore checklist + +Ensure local secrets and generated run artifacts are not committed unless the user explicitly chooses to commit sanitized outputs: + +```text +.env.local.user +env\.env.local.user +env\.env.*.user +.evals\ +*.log +``` + +`env\.env.` files can contain secrets in non-ATK projects. Treat them as local-only unless the repo has an established convention for checked-in, non-secret environment templates. + +## Troubleshooting setup + +| Symptom | Likely cause | Action | +|---|---|---| +| CLI prompts for EULA | EULA has not been accepted | Run `npx -y --package @microsoft/m365-copilot-eval@latest runevals accept-eula`. | +| `This version of the M365 Evals CLI has stopped working and must be updated` | Bare `runevals` is resolving a stale global/PATH install | Use `npx -y --package @microsoft/m365-copilot-eval@latest runevals --version`; ask before removing the global package. | +| Auth fails before agent response | Missing consent, license, or sign-in session | Confirm tenant admin consent and signed-in M365 Copilot user. | +| Model/evaluator errors | Missing endpoint, key, deployment, or API version | Validate Azure OpenAI in Foundry Models values without printing secrets. | +| Agent not found | Wrong `M365_TITLE_ID`, `M365_AGENT_ID`, or environment | Use explicit `--m365-agent-id` and `--env`. | +| Schema validation fails | Dataset uses old format or invalid evaluator names | Validate against `references\prompts-schema.json`. | +| Node error | Node version below public requirement | Upgrade Node.js to 24.12.0 or newer. | + +Never paste keys, tenant data, raw prompts, retrieved grounding data, or debug logs into chat unless the user confirms the content is safe to share. diff --git a/plugins/microsoft-365-agents-toolkit/skills/m365-agent-evaluator/references/eval-templates.md b/plugins/microsoft-365-agents-toolkit/skills/m365-agent-evaluator/references/eval-templates.md new file mode 100644 index 0000000..fae8a1c --- /dev/null +++ b/plugins/microsoft-365-agents-toolkit/skills/m365-agent-evaluator/references/eval-templates.md @@ -0,0 +1,172 @@ +# Evaluation dataset templates + +Use these templates when creating or editing `@microsoft/m365-copilot-eval` datasets. The current public schema uses `schemaVersion: "1.2.0"` and a root `items` array. + +Use `references\prompts-schema.json` as the local schema source. + +## Minimal single-turn dataset + +```json +{ + "schemaVersion": "1.2.0", + "metadata": { + "name": "Agent starter evaluation", + "description": "Smoke tests for core agent behavior.", + "tags": ["starter", "single-turn"] + }, + "default_evaluators": { + "Relevance": {}, + "Coherence": {} + }, + "items": [ + { + "prompt": "What can this agent help me with?", + "expected_response": "The agent explains its supported scope without claiming unsupported capabilities." + }, + { + "prompt": "Summarize the latest status for Contoso renewal.", + "expected_response": "The agent summarizes available status and avoids inventing facts when source data is unavailable." + } + ] +} +``` + +## Single-turn item with context and groundedness + +Use `context` when you have source material the answer must stay grounded in. + +```json +{ + "prompt": "What is the renewal deadline?", + "expected_response": "The renewal deadline is May 31.", + "context": "The Contoso renewal brief says the renewal deadline is May 31.", + "evaluators": { + "Groundedness": { + "threshold": 4 + }, + "Similarity": { + "threshold": 3 + } + }, + "evaluators_mode": "extend" +} +``` + +`evaluators_mode: "extend"` adds item-level evaluators to `default_evaluators`. Use `"replace"` when the item should use only the item-level evaluator set. + +## Evaluator threshold examples + +Public configurable evaluator names are case-sensitive: + +```json +{ + "default_evaluators": { + "Relevance": { + "threshold": 3 + }, + "Coherence": { + "threshold": 3 + }, + "Groundedness": { + "threshold": 3 + }, + "Similarity": { + "threshold": 3 + }, + "Citations": { + "threshold": 1 + }, + "PartialMatch": { + "threshold": 0.5 + } + } +} +``` + +Use `ExactMatch` sparingly. It is best for deterministic values such as IDs, dates, or fixed policy labels: + +```json +{ + "prompt": "Return only the ticket ID for the escalation.", + "expected_response": "INC-12345", + "evaluators": { + "ExactMatch": {} + }, + "evaluators_mode": "replace" +} +``` + +## Multi-turn dataset + +Use multi-turn items when the agent must preserve context across a conversation. The schema allows up to 20 turns. + +```json +{ + "schemaVersion": "1.2.0", + "metadata": { + "name": "Multi-turn follow-up suite", + "tags": ["multi-turn", "regression"] + }, + "default_evaluators": { + "Relevance": {}, + "Coherence": {} + }, + "items": [ + { + "name": "Follow-up retains project context", + "description": "The agent should remember that the user is discussing the Contoso renewal.", + "conversation_id": "contoso-renewal-followup", + "turns": [ + { + "prompt": "What is the latest status for the Contoso renewal?", + "expected_response": "The agent gives the available Contoso renewal status without inventing missing details." + }, + { + "prompt": "Who owns the next step?", + "expected_response": "The agent answers in the context of the Contoso renewal and cites or qualifies the source of the owner." + } + ] + } + ] +} +``` + +Do not put top-level `prompt` on a multi-turn item. Put prompts inside `turns`. + +## PRA scenario design + +Use PRA to choose what to test, then express tests with public evaluators. + +| PRA area | Test intent | Useful evaluators | +|---|---|---| +| Perceive | Finds the right source, respects available context, uses citations where required | `Groundedness`, `Citations`, `Relevance` | +| Reason | Synthesizes, follows instructions, handles ambiguity, avoids hallucination | `Relevance`, `Coherence`, `Similarity` | +| Act | Performs or describes declared capabilities accurately | `Relevance`, `Coherence`, `ExactMatch`, `PartialMatch`, `Similarity` | + +Do not add legacy/private evaluator names to generated datasets unless a current authoritative public schema includes them. + +## Tags and custom metadata + +Use root `metadata.tags` for suite-level tags. Use `extensions` for local metadata that the CLI should preserve: + +```json +{ + "prompt": "List the open actions from the last planning discussion.", + "expected_response": "The agent lists action items with owners when available.", + "extensions": { + "scenario": "action-items", + "risk": "hallucinated-owner", + "priority": "high" + } +} +``` + +## Authoring checklist + +1. Use `schemaVersion: "1.2.0"` and root `items`. +2. Include clear `expected_response` text for every item where comparison matters. +3. Keep prompts realistic but sanitized. +4. Use only public evaluator names: `Relevance`, `Coherence`, `Groundedness`, `Similarity`, `Citations`, `ExactMatch`, `PartialMatch`. +5. Use `Citations` as a minimum citation count, not as a 1-5 LLM score. +6. Prefer `Similarity` or `PartialMatch` for flexible expected answers; use `ExactMatch` only when exact text is intended. +7. Keep generated datasets in `evals\`; write run outputs under `.evals\`. diff --git a/plugins/microsoft-365-agents-toolkit/skills/m365-agent-evaluator/references/guardrails.md b/plugins/microsoft-365-agents-toolkit/skills/m365-agent-evaluator/references/guardrails.md new file mode 100644 index 0000000..bf04021 --- /dev/null +++ b/plugins/microsoft-365-agents-toolkit/skills/m365-agent-evaluator/references/guardrails.md @@ -0,0 +1,74 @@ +# Guardrails and troubleshooting + +Use this reference before writing files, handling secrets, running state-changing commands, or diagnosing failures. + +## Safety rules + +1. Never print, commit, or summarize secret values from `.env.local.user`, `env\.env.local.user`, `env\.env.`, system environment variables, or terminal output. +2. Treat prompts, agent responses, retrieved grounding data, HTML reports, CSV files, JSON results, and debug logs as potentially sensitive. +3. Ask before overwriting an existing dataset or report. +4. Prefer source datasets under `evals\` and local run outputs under `.evals\`. +5. Keep generated run outputs out of commits unless the user explicitly confirms they are sanitized and should be committed. +6. Do not run real tenant-dependent evaluations without user-approved tenant, deployed-agent, and Azure OpenAI configuration. + +## File writes + +Safe defaults: + +| File | Default behavior | +|---|---| +| `evals\evals.json` | Ask before overwrite; this may be committed as a regression suite. | +| `.evals\*.json` | Generated result output; usually local-only. | +| `.evals\*.csv` | Generated result output; usually local-only. | +| `.evals\*.html` | Generated result output; usually local-only and may contain response content. | +| `.env.local` | Non-secret ATK config only. | +| `.env.local.user`, `env\.env.local.user`, `env\.env.` | Secrets/local config; never commit or display values. | + +When generating a dataset, write to a new file or a temporary file first, then rename after validation. + +## Commands that change local state + +Warn the user before running: + +```powershell +npx -y --package @microsoft/m365-copilot-eval@latest runevals --cache-clear +npx -y --package @microsoft/m365-copilot-eval@latest runevals --signout +``` + +`--cache-clear` removes local cache data. `--signout` clears the local auth session and may require the user to sign in again. + +## Setup troubleshooting + +| Symptom | Likely cause | Action | +|---|---|---| +| EULA prompt or refusal | EULA not accepted | Run `npx -y --package @microsoft/m365-copilot-eval@latest runevals accept-eula`. | +| `This version of the M365 Evals CLI has stopped working and must be updated` | The shell resolved an outdated bare/global `runevals` shim | Use `npx -y --package @microsoft/m365-copilot-eval@latest runevals --version`; ask before uninstalling global packages or deleting shims. | +| `node` or package startup error | Node version too old | Upgrade to Node.js 24.12.0 or newer. | +| Agent cannot be resolved | Missing or wrong `M365_TITLE_ID`, `M365_AGENT_ID`, `--m365-agent-id`, or `--env` | Pass `--m365-agent-id` explicitly and verify the selected env file. | +| Authentication failure | Not signed in, unsupported OS auth path, missing M365 Copilot license, or tenant admin consent missing | Validate signed-in account, license, and consent. | +| Azure evaluator failure | Missing endpoint/key/model/API version | Check env keys exist without printing values; prefer `gpt-4o-mini`. | +| Schema validation error | Old dataset format or unsupported evaluator | Use root `items` and public evaluator names only. | +| Network/proxy errors | Enterprise network blocks auth/model calls | Ask the user to validate network/proxy access; do not retry with exposed secrets. | + +## Quality troubleshooting + +Do not treat every failed run as an agent bug. + +| Failure type | Category | +|---|---| +| Missing env, auth, consent, EULA, schema, or network | Setup failure | +| Low relevance, coherence, groundedness, similarity, citations, exact match, or partial match | Agent/eval quality signal | +| Ambiguous prompt or overly strict expected response | Eval dataset issue | +| Missing source data or inaccessible connector | Capability/data issue | + +Fix setup failures first. Then analyze quality signals with `references\result-analysis.md`. + +## Debug logging + +Use debug logging only when needed: + +```powershell +npx -y --package @microsoft/m365-copilot-eval@latest runevals --log-level debug --prompts-file evals\evals.json --output .evals\debug.json +``` + +Debug output may expose prompts, responses, URLs, identifiers, or retrieved content. Do not paste logs into chat or commit them unless the user confirms they are safe. diff --git a/plugins/microsoft-365-agents-toolkit/skills/m365-agent-evaluator/references/output-schema.json b/plugins/microsoft-365-agents-toolkit/skills/m365-agent-evaluator/references/output-schema.json new file mode 100644 index 0000000..9698e01 --- /dev/null +++ b/plugins/microsoft-365-agents-toolkit/skills/m365-agent-evaluator/references/output-schema.json @@ -0,0 +1,129 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "M365 Copilot Eval CLI JSON Output", + "description": "Compact schema for JSON files written by runevals --output. Output is eval-document compatible and contains evaluated items with sparse score objects.", + "type": "object", + "required": ["schemaVersion", "items"], + "additionalProperties": true, + "properties": { + "schemaVersion": { + "type": "string", + "pattern": "^1\\.\\d+\\.\\d+$" + }, + "metadata": { + "type": "object", + "additionalProperties": true, + "properties": { + "evaluatedAt": { "type": "string" }, + "agentId": { "type": "string" }, + "agentName": { "type": "string" }, + "cliVersion": { "type": "string" } + } + }, + "default_evaluators": { + "type": "object", + "additionalProperties": true + }, + "items": { + "type": "array", + "minItems": 1, + "items": { + "oneOf": [ + { "$ref": "#/$defs/singleTurnOutput" }, + { "$ref": "#/$defs/multiTurnOutput" } + ] + } + } + }, + "$defs": { + "singleTurnOutput": { + "type": "object", + "required": ["prompt"], + "additionalProperties": true, + "properties": { + "prompt": { "type": "string" }, + "response": { "type": "string" }, + "expected_response": { "type": "string" }, + "context": { "type": "string" }, + "evaluators": { "type": "object", "additionalProperties": true }, + "evaluators_mode": { "enum": ["extend", "replace"] }, + "scores": { "$ref": "#/$defs/scoreMap" } + }, + "not": { + "required": ["turns"] + } + }, + "multiTurnOutput": { + "type": "object", + "required": ["turns"], + "additionalProperties": true, + "properties": { + "name": { "type": "string" }, + "description": { "type": "string" }, + "conversation_id": { "type": "string" }, + "turns": { + "type": "array", + "minItems": 1, + "maxItems": 20, + "items": { + "type": "object", + "additionalProperties": true, + "properties": { + "prompt": { "type": "string" }, + "response": { "type": "string" }, + "expected_response": { "type": "string" }, + "scores": { "$ref": "#/$defs/scoreMap" } + } + } + }, + "summary": { "type": "object", "additionalProperties": true } + }, + "not": { + "required": ["prompt"] + } + }, + "scoreMap": { + "type": "object", + "additionalProperties": false, + "properties": { + "relevance": { "$ref": "#/$defs/llmScore" }, + "coherence": { "$ref": "#/$defs/llmScore" }, + "groundedness": { "$ref": "#/$defs/llmScore" }, + "similarity": { "$ref": "#/$defs/llmScore" }, + "citations": { "$ref": "#/$defs/citationScore" }, + "exactMatch": { "$ref": "#/$defs/matchScore" }, + "partialMatch": { "$ref": "#/$defs/matchScore" } + } + }, + "llmScore": { + "type": "object", + "required": ["score", "result", "threshold"], + "additionalProperties": true, + "properties": { + "score": { "type": "number", "minimum": 1, "maximum": 5 }, + "result": { "enum": ["pass", "fail"] }, + "threshold": { "type": "number" } + } + }, + "citationScore": { + "type": "object", + "required": ["count", "result", "threshold"], + "additionalProperties": true, + "properties": { + "count": { "type": "number", "minimum": 0 }, + "result": { "enum": ["pass", "fail"] }, + "threshold": { "type": "number", "minimum": 0 } + } + }, + "matchScore": { + "type": "object", + "required": ["score", "result", "threshold"], + "additionalProperties": true, + "properties": { + "score": { "type": "number", "minimum": 0, "maximum": 1 }, + "result": { "enum": ["pass", "fail"] }, + "threshold": { "type": "number", "minimum": 0, "maximum": 1 } + } + } + } +} diff --git a/plugins/microsoft-365-agents-toolkit/skills/m365-agent-evaluator/references/output-schema.md b/plugins/microsoft-365-agents-toolkit/skills/m365-agent-evaluator/references/output-schema.md new file mode 100644 index 0000000..7a5c9f4 --- /dev/null +++ b/plugins/microsoft-365-agents-toolkit/skills/m365-agent-evaluator/references/output-schema.md @@ -0,0 +1,85 @@ +# Output schema reference + +The current `runevals --output ` JSON output is schema-compatible with the eval document format. It is not the older `{ "summary": ..., "results": [...] }` shape. + +Use `references\output-schema.json` for a compact validation-oriented schema and `references\prompts-schema.json` for the full package schema. + +## JSON output + +Typical JSON output: + +```json +{ + "schemaVersion": "1.2.0", + "metadata": { + "evaluatedAt": "2025-01-01T00:00:00Z", + "agentId": "00000000-0000-0000-0000-000000000000", + "agentName": "Contoso Agent", + "cliVersion": "1.5.0-preview.1" + }, + "default_evaluators": {}, + "items": [ + { + "prompt": "What can this agent help me with?", + "response": "The agent response.", + "expected_response": "The expected behavior.", + "scores": { + "relevance": { + "score": 4, + "result": "pass", + "threshold": 3 + }, + "coherence": { + "score": 5, + "result": "pass", + "threshold": 3 + } + } + } + ] +} +``` + +Multi-turn output uses an item with `turns` and may include `summary`. + +## Score object + +Scores are sparse. Keys appear only for evaluators that ran. + +| Score key | Value shape | +|---|---| +| `relevance` | `{ "score": 1-5, "result": "pass" | "fail", "threshold": number }` | +| `coherence` | `{ "score": 1-5, "result": "pass" | "fail", "threshold": number }` | +| `groundedness` | `{ "score": 1-5, "result": "pass" | "fail", "threshold": number }` | +| `similarity` | `{ "score": 1-5, "result": "pass" | "fail", "threshold": number }` | +| `citations` | `{ "count": number, "result": "pass" | "fail", "threshold": number }` | +| `exactMatch` | `{ "score": 0 | 1, "result": "pass" | "fail", "threshold": number }` | +| `partialMatch` | `{ "score": 0.0-1.0, "result": "pass" | "fail", "threshold": number }` | + +Do not treat missing score keys as failures. + +## CSV output + +CSV reports include: + +- metadata comments at the top, +- aggregate statistics, +- single-turn sections, +- multi-turn sections, +- serialized score JSON. + +Use CSV when the user wants spreadsheet review or lightweight automation without parsing full JSON. + +## HTML output + +HTML reports are for human review and may open in the default browser. Treat them as sensitive because they can contain prompts, retrieved data, and agent responses. + +## Automation guidance + +When parsing JSON results: + +1. Read `items`. +2. For each item, handle either single-turn `prompt` or multi-turn `turns`. +3. Check only `scores` keys that exist. +4. Treat setup/auth/model/schema errors separately from quality scores. +5. Compare runs only when datasets, evaluators, thresholds, and model configuration are stable. diff --git a/plugins/microsoft-365-agents-toolkit/skills/m365-agent-evaluator/references/pra-framework.md b/plugins/microsoft-365-agents-toolkit/skills/m365-agent-evaluator/references/pra-framework.md new file mode 100644 index 0000000..d80ec0b --- /dev/null +++ b/plugins/microsoft-365-agents-toolkit/skills/m365-agent-evaluator/references/pra-framework.md @@ -0,0 +1,102 @@ +# PRA scenario framework + +Use PRA to design a balanced evaluation suite. PRA is a test-design taxonomy, not a set of evaluator names. + +## Perceive + +Perceive scenarios test whether the agent finds, uses, and cites the right information. + +Good prompts: + +```text +Summarize the latest status for the Contoso renewal and cite the source. +What risks were identified in the most recent planning discussion? +List the open action items with owners, using only available source data. +``` + +Useful evaluators: + +| Evaluator | Why | +|---|---| +| `Groundedness` | Checks support from source/context. | +| `Citations` | Checks that required citations are present. | +| `Relevance` | Checks that the answer addresses the request. | + +Common fixes: improve grounding instructions, clarify source priority, require citations, or update the agent's knowledge/action configuration. + +## Reason + +Reason scenarios test synthesis, instruction following, ambiguity handling, and refusal behavior. + +Good prompts: + +```text +Compare the two proposed launch plans and recommend the lower-risk option. +The request is ambiguous: ask one clarifying question before answering. +Create a concise executive summary from the available project context. +``` + +Useful evaluators: + +| Evaluator | Why | +|---|---| +| `Relevance` | Checks that reasoning addresses the ask. | +| `Coherence` | Checks clarity and structure. | +| `Similarity` | Checks alignment with an expected conclusion. | +| `PartialMatch` | Checks that key terms or decisions are present without requiring exact text. | + +Common fixes: add response-format guidance, examples, ambiguity rules, refusal guidance, or priority rules for conflicting evidence. + +## Act + +Act scenarios test declared capability behavior, such as whether the agent can produce the expected final artifact, answer, or action-oriented output. + +Good prompts: + +```text +Draft a follow-up message with the three agreed actions. +Return only the escalation ticket ID. +Create a prioritized list of next steps for the renewal owner. +``` + +Useful evaluators: + +| Evaluator | Why | +|---|---| +| `ExactMatch` | Best for deterministic IDs or labels. | +| `PartialMatch` | Best for key-term coverage. | +| `Similarity` | Best for flexible expected outputs. | +| `Relevance` | Checks that the action output matches the prompt. | +| `Coherence` | Checks structure and readability. | + +Do not use legacy/private action-specific evaluator names in authored datasets unless the current public schema explicitly supports them. + +## Suite balance + +A strong suite usually includes: + +1. Happy-path prompts for the most important user jobs. +2. Edge cases where data is missing, ambiguous, stale, or conflicting. +3. Citation/grounding prompts for answers based on workplace data. +4. Deterministic output prompts for IDs, dates, statuses, or labels. +5. Multi-turn prompts that require follow-up context. +6. Regression prompts for bugs found in production feedback. + +Use `metadata.tags` or item `extensions` to label PRA coverage. + +Example item metadata: + +```json +{ + "prompt": "Who owns the next step for the Contoso renewal?", + "expected_response": "The agent identifies the owner only if source data supports it.", + "evaluators": { + "Groundedness": {}, + "Citations": {} + }, + "extensions": { + "pra": "perceive", + "risk": "unsupported-owner" + } +} +``` diff --git a/plugins/microsoft-365-agents-toolkit/skills/m365-agent-evaluator/references/prompts-schema.json b/plugins/microsoft-365-agents-toolkit/skills/m365-agent-evaluator/references/prompts-schema.json new file mode 100644 index 0000000..a6ea3c9 --- /dev/null +++ b/plugins/microsoft-365-agents-toolkit/skills/m365-agent-evaluator/references/prompts-schema.json @@ -0,0 +1,484 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://raw.githubusercontent.com/microsoft/M365-Copilot-Agent-Evals/refs/heads/main/schema/v1/eval-document.schema.json", + "title": "M365 Copilot Eval Document", + "description": "Schema for evaluation documents used by M365 Copilot Agent Evals CLI. Supports single-turn and multi-turn evaluations.", + "type": "object", + "required": ["schemaVersion", "items"], + "additionalProperties": true, + "properties": { + "$schema": { + "type": "string", + "format": "uri", + "description": "JSON Schema URI for editor validation support" + }, + "schemaVersion": { + "type": "string", + "pattern": "^1\\.\\d+\\.\\d+$", + "description": "SemVer string identifying the schema version this document conforms to (e.g., '1.0.0')", + "examples": ["1.0.0", "1.1.0", "1.2.0"] + }, + "metadata": { + "$ref": "#/$defs/DocumentMetadata" + }, + "default_evaluators": { + "$ref": "#/$defs/EvaluatorMap", + "description": "File-level default evaluators (overrides system defaults)" + }, + "items": { + "type": "array", + "minItems": 1, + "description": "Array of evaluation items: single-turn evaluations or multi-turn threads", + "items": { + "oneOf": [ + { "$ref": "#/$defs/SingleTurnEvaluation" }, + { "$ref": "#/$defs/MultiTurnThread" } + ] + } + } + }, + "$defs": { + "DocumentMetadata": { + "type": "object", + "description": "Optional metadata about the evaluation document", + "additionalProperties": true, + "properties": { + "name": { + "type": "string", + "description": "Human-readable name for the evaluation set" + }, + "description": { + "type": "string", + "description": "Description of what this evaluation set tests" + }, + "createdAt": { + "type": "string", + "format": "date-time", + "description": "ISO 8601 timestamp when the document was created" + }, + "createdBy": { + "type": "string", + "description": "Author or system that created the document" + }, + "evaluatedAt": { + "type": "string", + "format": "date-time", + "description": "ISO 8601 timestamp when evaluation was performed" + }, + "tags": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Tags for categorization and filtering" + }, + "agentId": { + "type": "string", + "description": "M365 Agent ID this evaluation targets" + }, + "agentName": { + "type": "string", + "description": "Name of the M365 agent this evaluation targets" + }, + "cliVersion": { + "type": "string", + "description": "Version of the M365 Copilot Agent Evals CLI that produced this document" + }, + "extensions": { + "type": "object", + "additionalProperties": true, + "description": "Extension point for custom metadata. Use reverse-domain notation for field names." + } + } + }, + "SingleTurnEvaluation": { + "type": "object", + "description": "A standalone single-turn prompt-response evaluation", + "required": ["prompt"], + "additionalProperties": false, + "properties": { + "prompt": { + "type": "string", + "minLength": 1, + "description": "The input prompt to evaluate" + }, + "expected_response": { + "type": "string", + "description": "Expected or ideal response for comparison during evaluation" + }, + "response": { + "type": "string", + "description": "Actual response from the agent" + }, + "context": { + "type": "string", + "description": "Additional context for grounding evaluation" + }, + "evaluators": { + "$ref": "#/$defs/EvaluatorMap", + "description": "Per-prompt evaluator overrides" + }, + "evaluators_mode": { + "type": "string", + "enum": ["extend", "replace"], + "default": "extend", + "description": "How per-prompt evaluators combine with defaults" + }, + "citations": { + "type": "array", + "items": { + "$ref": "#/$defs/Citation" + }, + "description": "Citations included in the response" + }, + "scores": { + "$ref": "#/$defs/ScoreCollection" + }, + "extensions": { + "type": "object", + "additionalProperties": true, + "description": "Extension point for custom item-level fields" + } + }, + "not": { + "required": ["turns"] + } + }, + "MultiTurnThread": { + "type": "object", + "description": "A multi-turn conversation thread with ordered turns sharing conversation context", + "required": ["turns"], + "additionalProperties": false, + "properties": { + "name": { + "type": "string", + "description": "Human-readable name for the thread" + }, + "description": { + "type": "string", + "description": "Description of what this thread tests" + }, + "turns": { + "type": "array", + "minItems": 1, + "maxItems": 20, + "items": { "$ref": "#/$defs/Turn" }, + "description": "Ordered array of conversation turns" + }, + "conversation_id": { + "type": "string", + "description": "Unique identifier for this conversation thread" + }, + "summary": { + "$ref": "#/$defs/ThreadSummary", + "description": "Aggregate statistics for the thread" + }, + "extensions": { + "type": "object", + "additionalProperties": true, + "description": "Extension point for custom thread-level fields" + } + }, + "not": { + "required": ["prompt"] + } + }, + "Turn": { + "type": "object", + "description": "A single turn within a multi-turn thread", + "required": ["prompt"], + "additionalProperties": false, + "properties": { + "prompt": { + "type": "string", + "minLength": 1, + "description": "The user message for this turn" + }, + "expected_response": { + "type": "string", + "description": "Expected agent response for this turn" + }, + "response": { + "type": "string", + "description": "Actual agent response" + }, + "context": { + "type": "string", + "description": "Additional context for grounding evaluation" + }, + "evaluators": { + "$ref": "#/$defs/EvaluatorMap", + "description": "Per-turn evaluator overrides" + }, + "evaluators_mode": { + "type": "string", + "enum": ["extend", "replace"], + "default": "extend", + "description": "How per-turn evaluators combine with defaults" + }, + "citations": { + "type": "array", + "items": { + "$ref": "#/$defs/Citation" + }, + "description": "Citations included in the response" + }, + "scores": { + "$ref": "#/$defs/ScoreCollection" + }, + "status": { + "type": "string", + "enum": ["pass", "fail", "error"], + "description": "Overall status of this turn" + }, + "error": { + "type": "string", + "description": "Error message if status is 'error'" + }, + "extensions": { + "type": "object", + "additionalProperties": true, + "description": "Extension point for custom turn-level fields" + } + } + }, + "ThreadSummary": { + "type": "object", + "description": "Aggregate statistics for a thread", + "required": ["turns_total", "turns_passed", "turns_failed", "overall_status"], + "additionalProperties": false, + "properties": { + "turns_total": { + "type": "integer", + "minimum": 1, + "description": "Total number of turns executed" + }, + "turns_passed": { + "type": "integer", + "minimum": 0, + "description": "Number of turns where all evaluators passed" + }, + "turns_failed": { + "type": "integer", + "minimum": 0, + "description": "Number of turns where any evaluator failed" + }, + "overall_status": { + "type": "string", + "enum": ["pass", "partial", "fail"], + "description": "pass: all turns passed, partial: some failed, fail: all failed or error" + } + } + }, + "ScoreCollection": { + "type": "object", + "description": "Collection of evaluation scores for an item", + "additionalProperties": true, + "properties": { + "relevance": { + "$ref": "#/$defs/EvalScore", + "description": "Relevance score (1-5)" + }, + "coherence": { + "$ref": "#/$defs/EvalScore", + "description": "Coherence score (1-5)" + }, + "groundedness": { + "$ref": "#/$defs/EvalScore", + "description": "Groundedness score (1-5)" + }, + "similarity": { + "$ref": "#/$defs/EvalScore", + "description": "Similarity score (1-5)" + }, + "citations": { + "$ref": "#/$defs/CitationScore", + "description": "Citation evaluation results" + }, + "exactMatch": { + "$ref": "#/$defs/ExactMatchScore", + "description": "Exact match evaluation result" + }, + "partialMatch": { + "$ref": "#/$defs/PartialMatchScore", + "description": "Partial match evaluation result" + } + } + }, + "EvalScore": { + "type": "object", + "description": "Standard evaluation score (1-5 scale)", + "required": ["score", "result", "threshold"], + "additionalProperties": true, + "properties": { + "score": { + "type": "number", + "minimum": 1, + "maximum": 5, + "description": "Numeric score from 1.0 (worst) to 5.0 (best)" + }, + "result": { + "type": "string", + "enum": ["pass", "fail"], + "description": "Pass/fail result based on threshold comparison" + }, + "threshold": { + "type": "number", + "minimum": 1, + "maximum": 5, + "description": "Threshold used for pass/fail determination" + }, + "reason": { + "type": "string", + "description": "Explanation of why this score was assigned" + }, + "evaluator": { + "type": "string", + "description": "Name or identifier of the evaluator that produced this score" + } + } + }, + "CitationScore": { + "type": "object", + "description": "Citation-specific evaluation score", + "required": ["count", "result", "threshold"], + "additionalProperties": true, + "properties": { + "count": { + "type": "integer", + "minimum": 0, + "description": "Number of citations found in the response" + }, + "result": { + "type": "string", + "enum": ["pass", "fail"], + "description": "Pass/fail result based on citation count vs threshold" + }, + "threshold": { + "type": "integer", + "minimum": 0, + "description": "Minimum required number of citations for pass" + }, + "format": { + "type": "string", + "description": "Citation format detected. Known values: 'oai_unicode', 'bracket', 'mixed'. Additional formats may be added.", + "examples": ["oai_unicode", "bracket", "mixed"] + }, + "citations": { + "type": "array", + "items": { + "$ref": "#/$defs/Citation" + }, + "description": "Parsed citation objects" + } + } + }, + "ExactMatchScore": { + "type": "object", + "description": "Exact match evaluation result", + "required": ["match", "result"], + "additionalProperties": true, + "properties": { + "match": { + "type": "boolean", + "description": "Whether response exactly matches expected_response (trimmed; case-insensitive by default)" + }, + "result": { + "type": "string", + "enum": ["pass", "fail"], + "description": "Pass when match is true, fail otherwise" + }, + "reason": { + "type": "string", + "description": "Explanation of the match result" + } + } + }, + "PartialMatchScore": { + "type": "object", + "description": "Partial match evaluation result", + "required": ["score", "result", "threshold"], + "additionalProperties": true, + "properties": { + "score": { + "type": "number", + "minimum": 0, + "maximum": 1, + "description": "Match score from 0.0 (no match) to 1.0 (full match)" + }, + "result": { + "type": "string", + "enum": ["pass", "fail"], + "description": "Pass/fail based on score vs threshold" + }, + "threshold": { + "type": "number", + "minimum": 0, + "maximum": 1, + "description": "Minimum score required for pass (default: 0.5)" + }, + "reason": { + "type": "string", + "description": "Explanation of the match result" + } + } + }, + "EvaluatorMap": { + "type": "object", + "description": "Map of evaluator names to their configuration options", + "propertyNames": { + "enum": ["Relevance", "Coherence", "Groundedness", "Similarity", "Citations", "ExactMatch", "PartialMatch"] + }, + "additionalProperties": { + "$ref": "#/$defs/EvaluatorOptions" + } + }, + "EvaluatorOptions": { + "type": "object", + "description": "Evaluator configuration options. Use empty object {} for defaults.", + "additionalProperties": false, + "properties": { + "threshold": { + "type": "number", + "description": "Pass/fail threshold. Range depends on evaluator type: 1-5 for LLM evaluators (default: 3), >= 1 integer for Citations (min citation count, default: 1), 0.0-1.0 for PartialMatch (min match ratio, default: 0.5). Validated per-evaluator at runtime." + }, + "citation_format": { + "type": "string", + "examples": ["oai_unicode", "bracket", "mixed"], + "description": "Citation format for detection. 'oai_unicode': new OAI unicode format, 'bracket': legacy [^i^] bracket format, 'mixed': auto-detect both formats. Default: oai_unicode." + }, + "case_sensitive": { + "type": "boolean", + "default": false, + "description": "Case-sensitive matching for ExactMatch/PartialMatch" + }, + "options": { + "type": "object", + "additionalProperties": true, + "description": "Evaluator-specific configuration" + } + } + }, + "Citation": { + "type": "object", + "description": "A single citation reference", + "required": ["index"], + "additionalProperties": true, + "properties": { + "index": { + "type": "integer", + "minimum": 1, + "description": "Citation index (1-based)" + }, + "text": { + "type": "string", + "description": "The cited text" + }, + "source": { + "type": "string", + "description": "Source reference (URL, document name, etc.)" + } + } + } + } +} diff --git a/plugins/microsoft-365-agents-toolkit/skills/m365-agent-evaluator/references/remediation-patterns.md b/plugins/microsoft-365-agents-toolkit/skills/m365-agent-evaluator/references/remediation-patterns.md new file mode 100644 index 0000000..9a967b6 --- /dev/null +++ b/plugins/microsoft-365-agents-toolkit/skills/m365-agent-evaluator/references/remediation-patterns.md @@ -0,0 +1,81 @@ +# Remediation patterns + +Use these patterns when converting eval failures into concrete changes to agent instructions, manifest configuration, knowledge sources, or the eval dataset. + +## Before recommending changes + +1. Confirm the run reached the agent and evaluator model successfully. +2. Separate setup failures from quality failures. +3. Check whether the failed evaluator was configured for that item. +4. Inspect prompt, expected response, actual response, and context for ambiguity or sensitive content. +5. Recommend the smallest targeted change. + +## Failure-to-fix map + +| Failure | Likely cause | Targeted fix | +|---|---|---| +| Low relevance | Agent answers adjacent intent or ignores constraints | Strengthen instructions about supported scope and task routing; add examples for common intents. | +| Low coherence | Answer is hard to scan or mixes unrelated content | Add response format requirements, length limits, headings, or ordered steps. | +| Low groundedness | Answer includes unsupported facts | Require source-backed answers, clarify source priority, and instruct the agent to say when evidence is missing. | +| Low similarity | Actual content differs from expected behavior | Update instructions/data access, or relax the expected response if multiple valid answers exist. | +| Failed citations | No citation when one is required | Add citation requirements and verify the underlying agent capability can surface citations. | +| Failed exact match | Formatting or deterministic value differs | Add strict output-only instructions, or switch to `PartialMatch` if exact text is too brittle. | +| Low partial match | Key terms missing | Add expected terms to instructions/examples or improve retrieval for the missing concepts. | +| Multi-turn follow-up failure | Agent loses conversation context | Add follow-up handling examples and clarify how to resolve pronouns or references. | + +## Instruction remediation examples + +Grounding: + +```text +Answer using only information available from the retrieved workplace sources. If the sources do not contain enough evidence, say what is missing instead of guessing. +``` + +Citations: + +```text +When summarizing workplace information, include citations for the source messages, meetings, or documents whenever citations are available. +``` + +Scope: + +```text +If the user asks for work outside this agent's supported scenarios, briefly explain the supported scope and offer a related prompt the agent can answer. +``` + +Format: + +```text +Use this response shape: Summary, Key details, Open risks, Next actions. Keep each section concise. +``` + +Multi-turn: + +```text +For follow-up questions, preserve the active project, customer, and time window from the prior turn unless the user changes them. +``` + +## Eval dataset remediation + +Sometimes the eval is the problem. Update the dataset when: + +- the prompt is ambiguous outside hidden context, +- the expected response demands exact phrasing when flexible phrasing is acceptable, +- the evaluator threshold is stricter than the user requirement, +- the prompt contains stale or unavailable source data, +- a setup failure was recorded as if it were an agent-quality failure. + +Prefer adding a new regression item for each distinct production issue instead of overloading one prompt with many requirements. + +## Reporting format + +When handing back recommendations, use this shape: + +```text +Primary issue: +Evidence: +Recommended change: +Expected effect: +``` + +Do not include raw sensitive prompts, responses, retrieved data, or debug logs. diff --git a/plugins/microsoft-365-agents-toolkit/skills/m365-agent-evaluator/references/result-analysis.md b/plugins/microsoft-365-agents-toolkit/skills/m365-agent-evaluator/references/result-analysis.md new file mode 100644 index 0000000..e988dd3 --- /dev/null +++ b/plugins/microsoft-365-agents-toolkit/skills/m365-agent-evaluator/references/result-analysis.md @@ -0,0 +1,94 @@ +# Result analysis + +Use this reference after an evaluation run has produced JSON, CSV, or HTML output. + +## Current output shape + +JSON output is an eval-document-style object: + +```json +{ + "schemaVersion": "1.2.0", + "metadata": { + "evaluatedAt": "2025-01-01T00:00:00Z", + "agentId": "", + "agentName": "", + "cliVersion": "" + }, + "default_evaluators": {}, + "items": [ + { + "prompt": "What can this agent help me with?", + "response": "The agent response.", + "expected_response": "The expected behavior.", + "scores": { + "relevance": { + "score": 4, + "result": "pass", + "threshold": 3 + } + } + } + ] +} +``` + +The `scores` object is sparse. A missing score key usually means that evaluator was not configured for that item. + +CSV output includes metadata comments, aggregate statistics, single-turn and multi-turn sections, and serialized score JSON. HTML output is best for human review and may open in a browser. + +## Score keys and semantics + +| Score key | Authoring evaluator | Interpretation | +|---|---|---| +| `relevance` | `Relevance` | 1-5 LLM score for whether the answer addresses the prompt. | +| `coherence` | `Coherence` | 1-5 LLM score for clarity and structure. | +| `groundedness` | `Groundedness` | 1-5 LLM score for support from provided or retrieved context. | +| `similarity` | `Similarity` | 1-5 LLM comparison to `expected_response`. | +| `citations` | `Citations` | Count-based citation result against the configured threshold. | +| `exactMatch` | `ExactMatch` | Boolean exact match result. | +| `partialMatch` | `PartialMatch` | 0.0-1.0 partial string match result. | + +Analyze only keys that are present. Do not assume a missing evaluator failed. + +## Triage patterns + +| Signal | Likely root cause | Recommended remediation | +|---|---|---| +| Low `relevance` | Wrong intent, vague prompt, or agent ignored the ask | Clarify instructions, add examples, improve scenario routing, or update the eval prompt if it is ambiguous. | +| Low `coherence` | Response is disorganized, contradictory, or too verbose | Tighten response-format instructions and add expected structure. | +| Low `groundedness` | Response uses unsupported facts or ignores context | Improve grounding instructions, retrieval configuration, source selection, or refusal behavior when evidence is missing. | +| Low `similarity` | Actual response diverges from expected answer | Check whether expected response is too rigid; otherwise update instructions or data access. | +| Failed `citations` | Response lacks required source citations | Add citation requirements to instructions and verify source-citation support in the agent. | +| Failed `exactMatch` | Deterministic output is not exact | Use stricter formatting instructions or replace with `PartialMatch` if exact text is not necessary. | +| Low `partialMatch` | Key expected terms are missing | Add expected terminology to instructions or adjust expected response to match acceptable variants. | +| Auth, consent, schema, or model errors | Environment failure | Fix setup before judging agent quality. | + +## Multi-turn analysis + +For multi-turn items, check whether failure starts on the first failing turn or compounds across turns: + +1. First-turn failure usually means prompt handling, grounding, or data access is broken. +2. Later-turn failure usually means the agent loses conversation context or mishandles follow-up references. +3. Mixed pass/fail patterns can indicate evaluator thresholds are too strict for some turns. + +Keep conversation IDs and turn order stable when comparing runs. + +## Comparing runs over time + +For regression checks: + +1. Keep the same dataset and expected responses. +2. Keep evaluator names and thresholds stable. +3. Use the same Azure OpenAI model deployment when comparing score movement. +4. Save outputs with explicit names, for example `.evals\2025-01-01-baseline.json` and `.evals\2025-01-02-after-instructions.json`. +5. Separate environment failures from quality failures before reporting a pass rate. + +## Reporting recommendations + +When summarizing results to the user: + +- Lead with pass/fail themes, not raw JSON. +- Group failures by likely fix area: instructions, grounding/retrieval, citations, expected-answer quality, capability gap, or setup. +- Include the smallest concrete manifest/instruction change that is likely to address the issue. +- Do not paste raw prompts, retrieved data, responses, or logs if they may contain sensitive content. diff --git a/plugins/microsoft-365-agents-toolkit/skills/m365-agent-evaluator/references/workflow.md b/plugins/microsoft-365-agents-toolkit/skills/m365-agent-evaluator/references/workflow.md new file mode 100644 index 0000000..4e00a77 --- /dev/null +++ b/plugins/microsoft-365-agents-toolkit/skills/m365-agent-evaluator/references/workflow.md @@ -0,0 +1,199 @@ +# M365 Copilot eval workflow + +Use this workflow when the user wants to set up, author, run, or analyze evaluations with the public preview `@microsoft/m365-copilot-eval` CLI. + +## Canonical command + +Always invoke the CLI through the public npm package: + +```powershell +npx -y --package @microsoft/m365-copilot-eval@latest runevals +``` + +Do not use private-preview installers, global installs, bare `runevals`, bare `npx runevals`, or retired flags such as `--input` and `--html`. + +## 1. Detect project shape + +Default to the Agents Toolkit path, but support explicit agent IDs for non-ATK projects. + +| Project shape | Signals | Agent ID source | +|---|---|---| +| ATK / Teams Toolkit | `.env.local`, `.env.local.user`, `env\.env.local.user`, `m365agents.yml`, `appPackage\declarativeAgent.json` | `M365_TITLE_ID` from `.env.local`, or `M365_AGENT_ID` | +| Non-ATK | Eval dataset plus named env files or explicit CLI args | `M365_AGENT_ID` or `--m365-agent-id` | + +If no agent ID can be found, ask the user for the deployed M365 Copilot agent ID or tell them to add it to a local env file. + +## 2. Verify local prerequisites + +Run safe checks that do not reveal secret values: + +```powershell +node --version +npx -y --package @microsoft/m365-copilot-eval@latest runevals --version +npx -y --package @microsoft/m365-copilot-eval@latest runevals --help +``` + +Current public docs require Node.js 24.12.0 or newer and describe authentication support as Windows-first. The user also needs a Microsoft 365 Copilot license, a deployed M365 Copilot agent, tenant admin consent for the WorkIQ Client App, and Azure OpenAI in Foundry Models configuration. + +Also check for stale global/PATH installs before troubleshooting the agent: + +```powershell +Get-Command runevals -All +npm list -g @microsoft/m365-copilot-eval --depth=0 +npm view @microsoft/m365-copilot-eval version +npx -y --package @microsoft/m365-copilot-eval@latest where runevals +``` + +If bare `runevals` reports `This version of the M365 Evals CLI has stopped working and must be updated`, the shell is resolving an outdated global shim. Use the package-scoped `npx --package @microsoft/m365-copilot-eval@latest` command, and only remove the global package after user confirmation. + +## 3. Accept the EULA and initialize + +Use these commands for first-time setup: + +```powershell +npx -y --package @microsoft/m365-copilot-eval@latest runevals accept-eula +npx -y --package @microsoft/m365-copilot-eval@latest runevals --init-only +``` + +`--init-only` validates setup and can create starter files without running a full tenant-dependent evaluation. + +## 4. Prepare env files + +Use `references\azure-setup.md` for full setup details. Keep secrets out of `.env.local`. + +Minimal ATK layout: + +```text +.env.local # non-secret ATK values, for example M365_TITLE_ID +.env.local.user # local secrets +env\.env.local.user # alternate local secrets path +``` + +Minimal non-ATK layout: + +```text +env\.env.dev # named environment selected with --env dev +evals\evals.json # evaluation dataset +``` + +Required values are: + +```text +TENANT_ID= +AZURE_AI_OPENAI_ENDPOINT= +AZURE_AI_API_KEY= +``` + +Recommended/default model values: + +```text +AZURE_AI_API_VERSION=2024-12-01-preview +AZURE_AI_MODEL_NAME=gpt-4o-mini +``` + +Set one of: + +```text +M365_TITLE_ID= +M365_AGENT_ID= +``` + +## 5. Create or validate the dataset + +The CLI auto-discovers `prompts.json`, `evals.json`, or `tests.json` in the current directory or in `evals\`. Prefer `evals\evals.json` for new work. + +Use schema version `1.2.0` with root `items`: + +```json +{ + "schemaVersion": "1.2.0", + "metadata": { + "name": "Agent regression suite", + "tags": ["regression"] + }, + "default_evaluators": { + "Relevance": {}, + "Coherence": {} + }, + "items": [ + { + "prompt": "What can this agent help me with?", + "expected_response": "The agent describes only supported capabilities." + } + ] +} +``` + +Use `references\eval-templates.md` for copyable single-turn, multi-turn, and evaluator-threshold examples. + +## 6. Run evaluations + +Batch run with explicit output: + +```powershell +npx -y --package @microsoft/m365-copilot-eval@latest runevals --prompts-file evals\evals.json --output .evals\results.json +``` + +Human-review HTML: + +```powershell +npx -y --package @microsoft/m365-copilot-eval@latest runevals --prompts-file evals\evals.json --output .evals\results.html +``` + +Spreadsheet-friendly CSV: + +```powershell +npx -y --package @microsoft/m365-copilot-eval@latest runevals --prompts-file evals\evals.json --output .evals\results.csv +``` + +Inline smoke test: + +```powershell +npx -y --package @microsoft/m365-copilot-eval@latest runevals --prompts "What can you help me with?" --expected "The agent explains its supported scope." +``` + +Interactive exploration: + +```powershell +npx -y --package @microsoft/m365-copilot-eval@latest runevals --interactive +``` + +Named environment: + +```powershell +npx -y --package @microsoft/m365-copilot-eval@latest runevals --prompts-file evals\evals.json --env dev +``` + +Explicit non-ATK agent: + +```powershell +npx -y --package @microsoft/m365-copilot-eval@latest runevals --prompts-file evals\evals.json --m365-agent-id --env dev +``` + +Controlled concurrency: + +```powershell +npx -y --package @microsoft/m365-copilot-eval@latest runevals --prompts-file evals\evals.json --concurrency 1 --output .evals\debug.json +npx -y --package @microsoft/m365-copilot-eval@latest runevals --prompts-file evals\evals.json --concurrency 5 --output .evals\batch.json +``` + +Use values from 1 to 5 only. Start with 1 while debugging auth, schema, or agent behavior. + +## 7. Manage local state + +Use these only when needed, and warn the user first because they affect local state: + +```powershell +npx -y --package @microsoft/m365-copilot-eval@latest runevals --cache-info +npx -y --package @microsoft/m365-copilot-eval@latest runevals --cache-dir +npx -y --package @microsoft/m365-copilot-eval@latest runevals --cache-clear +npx -y --package @microsoft/m365-copilot-eval@latest runevals --signout +``` + +`--cache-clear` can remove cached run data. `--signout` resets the local authentication session. + +## 8. Analyze and iterate + +Load `references\result-analysis.md` after a run. Treat setup/auth/schema failures separately from agent-quality failures. For regression comparisons, keep the same dataset, expected responses, evaluator set, thresholds, model deployment, and concurrency where possible. + +Do not run real evaluations unless the user has provided or approved the tenant, deployed agent, and Azure OpenAI configuration. diff --git a/plugins/microsoft-365-agents-toolkit/skills/teams-app-developer/SKILL.md b/plugins/microsoft-365-agents-toolkit/skills/teams-app-developer/SKILL.md new file mode 100644 index 0000000..286cabb --- /dev/null +++ b/plugins/microsoft-365-agents-toolkit/skills/teams-app-developer/SKILL.md @@ -0,0 +1,127 @@ +--- +name: teams-app-developer +description: > + Build, test, and deploy code-based Teams apps using the M365 Agents Toolkit CLI. + USE FOR: Custom Engine Agents (CEA), Teams bots, tabs, message extensions, Agents Playground + local testing, Azure provision/deploy, Slack-to-Teams migration, cross-platform bot development, + Block Kit to Adaptive Cards conversion, AI model integration (OpenAI/Azure/Anthropic/Bedrock). + DO NOT USE FOR: declarative agents — use the `declarative-agent-developer` skill instead. + Triggers: "build a teams bot", "custom engine agent", "CEA", "teams agent", "tab app", + "message extension", "test with agents playground", "provision to azure", "deploy to azure", + "migrate slack bot", "slack to teams", "convert block kit", "add azure openai to my bot" +--- + +# Teams App Developer Skill + +Build code-based Microsoft 365 agents and Teams apps (CEA, bot, tab, message extension) using the ATK CLI. + +> **Declarative Agents (DA):** Use the **`declarative-agent-developer`** skill — it owns all DA +> workflows including scaffolding, manifest editing, capability config, API/MCP plugins, OAuth, +> localization, and deployment. This skill covers code-based project types only. + +## AI Behavior Guidelines + +1. **Testing Strategy:** Recommend Agents Playground first (faster, no M365 needed). Use Teams workflow only if user explicitly requests it. + +2. **Environment Variables:** NEVER hardcode secrets or make up placeholder values. Always ask users for real values. + +3. **Error Handling:** Read error messages carefully. Check `env/.env.local`, `.localConfigs`, and `atk auth list`. Common pitfalls: + - **`AADSTS7000229`** → `aadApp/create` missing `generateServicePrincipal: true` in YAML — add it and re-provision + - **Missing `TENANT_ID`** in `.localConfigs` → SDK uses wrong token authority → 401 from Bot Connector + - **401 persists after auth fix** → devtunnel URL may be blacklisted — create a fresh tunnel + - See [troubleshoot/troubleshoot.md](troubleshoot/troubleshoot.md) for full diagnostic steps + +4. **Long-Running Commands — WAIT for completion:** + - `atk new`, `atk provision`, `atk deploy` can take several minutes + - Always wait for completion before running the next step (timeout 120000ms+) + +5. **Local Service Startup — Hangs terminal (expected):** + - `npm run dev`, `npm start`, `python app.py`, `devtunnel host`, etc. will hang — the process keeps running indefinitely + - ALWAYS run as a background process (`isBackground=true`) — NEVER use `isBackground=false` for these commands + - Do NOT wait for it to "finish" — verify startup by checking output for "listening on port" or tunnel URL + - If errors appear, read logs, diagnose, fix, restart + - Use a **NEW terminal** to launch Agents Playground or open Teams sideloading URL + +6. **Monitor App Logs:** Periodically check background terminal output for runtime errors. If the app crashes, read the error, fix the root cause, and restart. + +7. **Telemetry Tagging:** Before running any `atk` CLI commands, set the session environment variable so all CLI invocations are tagged as skill-initiated: + ```bash + export ATK_CLI_SKILL=true + ``` + Run this once at the start of the session. All subsequent `atk` commands in the same terminal will inherit it. + +## ATK CLI Setup + +Before any ATK commands, verify the CLI is available: + +```bash +atk --version # Must be > 1.1.5-beta +``` + +If ATK is not found or the version is too old → **use the `install-atk` skill** to install or +update it, then return here to continue. + +## CLI Global Options + +| Option | Meaning | Recommendation | +| --- | --- | --- | +| `-i` | Interactive mode | Always use `-i false` in automation to avoid hanging | +| `-f` | Project folder | Default to be current directory, used when specifying a custom folder. When scaffolding a new project, this is the parent folder where the project folder will be created under. | +| `-h` | Command help | Use `atk -h` for quick syntax checks | + +## Sub-Skills + +| Sub-Skill | When to Use | Reference | +|-----------|-------------|-----------| +| **create-project** | Scaffold new project from template, choose template, `atk new` | [create-project/create-project.md](create-project/create-project.md) | +| **test-playground** | Test locally with Agents Playground, `agentsplayground`, quick testing | [test-playground/test-playground.md](test-playground/test-playground.md) | +| **test-teams** | Run on Teams, devtunnel, sideload, Teams testing, test in Copilot | [test-teams/test-teams.md](test-teams/test-teams.md) | +| **provision-deploy** | Provision Azure resources, deploy to cloud, `atk provision`, `atk deploy` | [provision-deploy/provision-deploy.md](provision-deploy/provision-deploy.md) | +| **troubleshoot** | Fix errors, 401, port conflicts, YAML errors, stale bots | [troubleshoot/troubleshoot.md](troubleshoot/troubleshoot.md) | +| **slack-to-teams** | Migrate Slack bot to Teams, cross-platform bridging, Block Kit to Adaptive Cards | [slack-to-teams/SKILL.md](slack-to-teams/SKILL.md) | + +> **MANDATORY:** Before executing any workflow, read the corresponding sub-skill document. + +## Workflow Chains + +Match user intent to the smallest valid workflow. + +| User Intent | Workflow (read in order) | +|---|---| +| Build new CEA/bot/tab/ME from scratch | create-project → test-playground | +| Test existing project locally | test-playground (recommended) or test-teams | +| Deploy to Azure | provision-deploy | +| Fix broken bot | troubleshoot → re-test | +| Migrate Slack bot to Teams | slack-to-teams | +| Create Declarative Agent | **Use `declarative-agent-developer` skill** | + +> **MANDATORY:** Before executing any slack-to-teams workflow, read [slack-to-teams/SKILL.md](slack-to-teams/SKILL.md) first. The sub-skill contains a routed expert system with 100+ micro-expert files for cross-platform bot development. + +## Shared References + +- [manifest-and-yaml.md](toolkit/manifest-and-yaml.md) — Project files, YAML config, env vars, .localConfigs flow +- [commands.md](toolkit/commands.md) — ATK CLI commands: package, validate, share, collaborate +- [templates.md](toolkit/templates.md) — Complete template catalog with language support +- [experts/](experts/index.md) — 100+ micro-expert files: Teams SDK, Slack SDK, cross-platform bridging, deploy, AI models, security, language conversion +- [docs/](docs/README.md) — Platform comparison guides: UI, messaging, identity, infrastructure, feature gaps + +## ATK Project Context Resolution + +Resolve config values only when missing. If a value is already known in the session, reuse it. + +### Step 1: Detect ATK Project + +If `m365agentstoolkit*.yml` exists in the current folder, treat it as an ATK project and parse configuration. + +### Step 2: Resolve Common Configuration + +Resolve variables referenced in `m365agentstoolkit*.yml`. Common variables: +AZURE_OPENAI_API_KEY +AZURE_OPENAI_ENDPOINT +AZURE_OPENAI_DEPLOYMENT_NAME + +### Step 3: Collect Missing Values + +If required values are missing, ask the user for only the missing ones. + +Refer to [manifest-and-yaml.md](toolkit/manifest-and-yaml.md) for full config-file details. diff --git a/plugins/microsoft-365-agents-toolkit/skills/teams-app-developer/create-project/create-project.md b/plugins/microsoft-365-agents-toolkit/skills/teams-app-developer/create-project/create-project.md new file mode 100644 index 0000000..81e16f0 --- /dev/null +++ b/plugins/microsoft-365-agents-toolkit/skills/teams-app-developer/create-project/create-project.md @@ -0,0 +1,139 @@ +# Create Project + +Scaffold a new Microsoft 365 agent or Teams app from an ATK template. + +## Template Selection Guide + +> **Creating a Declarative Agent?** Use the **`declarative-agent-developer`** skill instead — it +> provides deeper guidance on DA scaffolding, manifest authoring, capability configuration, +> API/MCP plugin setup, OAuth, localization, and deployment. The templates below include DA +> options for reference, but the `declarative-agent-developer` skill owns that workflow end-to-end. + +| User Wants | Capability | +|------------|------------| +| Extend M365 Copilot with custom instructions | `declarative-agent` | +| Declarative Agent with new API | `declarative-agent-action` | +| Declarative Agent with new API (Bearer Token) | `declarative-agent-action-bearer` | +| Declarative Agent with new API (OAuth) | `declarative-agent-action-oauth` | +| Declarative Agent with existing OpenAPI spec | `declarative-agent-action-from-existing-api` | +| Connect MCP Server to Copilot | `declarative-agent-with-action-from-mcp` | +| Declarative Agent with Copilot Connector | `declarative-agent-with-graph-connector` | +| Declarative Agent for MetaOS | `declarative-agent-meta-os-new-project` | +| Declarative Agent from TypeSpec | `declarative-agent-typespec` | +| Agent with custom LLM (Azure OpenAI, etc.) | `basic-custom-engine-agent` | +| Weather forecast agent | `weather-agent` | +| Agent using Azure AI Foundry | `foundry-agent-to-m365` | +| Teams chatbot with AI | `teams-agent` | +| Teams bot with RAG/knowledge base | `teams-agent-rag-customize` | +| Teams Agent with Azure AI Search | `teams-agent-rag-azure-ai-search` | +| Teams Agent with Custom API | `teams-agent-rag-custom-api` | +| Teams Collaborator Agent | `teams-collaborator-agent` | +| Simple Teams echo bot | `bot` | +| Teams tab app | `tab` | +| Teams message extension | `message-extension` | +| Copilot Connector | `copilot-connector` | + +See [../toolkit/templates.md](../toolkit/templates.md) for the complete template catalog with language support and descriptions. + +## Creating Projects + +Create templates in the current directory with one generic flow: + +```bash +# 1) Scaffold into a temporary parent folder +atk new -c -n -f /tmp -l -i false + +# 2) Move generated files from the scaffold subfolder into current directory +mv /tmp//. . + +# 3) Remove the empty scaffold folder +rmdir /tmp/ +``` + +Common examples: + +```bash +# Declarative Agent (no -l needed) +atk new -c declarative-agent -n my-agent -f /tmp -i false + +# Declarative Agent with new API +atk new -c declarative-agent-action -l typescript -n my-api-agent -f /tmp -i false + +# Declarative Agent with existing OpenAPI spec +atk new -c declarative-agent-action-from-existing-api -n my-agent -a -o "GET /repairs" -o "POST /repairs" -f /tmp -i false + +# Custom Engine Agent +atk new -c basic-custom-engine-agent -l typescript -n my-cea -f /tmp -i false + +# Teams Agent with RAG +atk new -c teams-agent-rag-customize -l typescript -n my-rag-agent -f /tmp -i false +``` + +PowerShell equivalent: + +```powershell +# 1) Scaffold into temporary folder +atk new -c -n -f $env:TEMP -l -i false + +# 2) Move files into current directory +Move-Item "$env:TEMP\\*" . +Move-Item "$env:TEMP\\.*" . -ErrorAction SilentlyContinue + +# 3) Remove scaffold folder +Remove-Item "$env:TEMP\" -Force +``` + +## Creating from Samples + +```bash +atk new sample +``` + +To place sample files in current directory, scaffold first and then move files from the sample output folder into `.` using the same move pattern as above. + +| Sample | Sample ID (`atk new sample `) | Tags | +| ------------------------------------------------- | ---------------------------------------- | ----------------------------------------------------------------------------------------- | +| Langchain Agent with Agent365 SDK in NodeJS | `agent365-langchain-nodejs` | Agent365, TS | +| Agent Framework Agent with Agent365 SDK in Python | `agent365-agentframework-python` | Agent365, Python | +| OpenAI Agent with Agent365 SDK in Python | `agent365-openai-python` | Agent365, Python | +| Claude Agent with Agent365 SDK in NodeJS | `agent365-claude-nodejs` | Agent365, TS | +| Tab App with Azure Backend | `hello-world-tab-with-backend` | Tab, TS, Azure Functions, Dev Proxy | +| Bot App with SSO Enabled | `bot-sso` | Bot, TS, Adaptive Cards, SSO | +| Team Central Dashboard | `team-central-dashboard` | Tab, TS, Azure Functions, SSO | +| Copilot connector App | `copilot-connector-app` | Tab, Azure Functions, TS, SSO, Copilot connector | +| Teams Conversation Bot using Python | `bot-conversation-python` | Python, Bot, Bot Framework | +| Teams Messaging Extensions Search using Python | `msgext-search-python` | Python, Message extension, Bot Framework | +| Travel Agent | `travel-agent` | C#, Custom Engine Agent, M365 Copilot Retrieval API, Agents SDK, Agent Framework | +| Coffee Agent | `coffee-agent` | TS, Custom Engine Agent, Adaptive Cards, Microsoft Teams SDK | +| Data Analyst Agent v2 | `data-analyst-agent-v2` | TS, Custom Engine Agent, Data Visualization, Adaptive Cards, LLM SQL, Microsoft Teams SDK | + +List all samples with `atk list samples`. + +## Notes + +- `declarative-agent` does NOT require `-l` language flag +- `declarative-agent-action-from-existing-api` requires `-a` (OpenAPI spec) and `-o` (operation IDs like `"GET /path"`) +- Always use `-i false` for non-interactive scripted creation +- `atk new` can take several minutes — wait for completion (timeout 120000ms+) +- If template/sample already matches the requirement, do not run dependency install by default; continue only when user asks for next steps + +## After Scaffolding + +Once the project is created: +- To test locally → see [../test-playground/test-playground.md](../test-playground/test-playground.md) +- To understand project files → see [../toolkit/manifest-and-yaml.md](../toolkit/manifest-and-yaml.md) + +## Expert Deep Dives + +> **Applies to: code-based Teams bots/agents only** (templates: `bot`, `teams-agent*`, `basic-custom-engine-agent`, `weather-agent`, `coffee-agent`, `bot-sso`, `msgext-*`, `tab*`). +> +> Does **not** apply to declarative agents, API plugins, Copilot connectors, or `declarative-agent-*` / `copilot-connector` templates — those have no source code to scaffold against. For those, follow the in-template instructions and the [Microsoft 365 Copilot extensibility docs](https://learn.microsoft.com/microsoft-365-copilot/extensibility/) directly. + +For deeper guidance on what `atk new` produces and how to extend it, consult the Teams expert micro-files: + +| Topic | Expert | +|---|---| +| Project file layout, `package.json`, `tsconfig.json`, npm scripts, `appPackage/` | [../experts/teams/project.scaffold-files-ts.md](../experts/teams/project.scaffold-files-ts.md) | +| `App` constructor, plugins, credentials, runtime initialization | [../experts/teams/runtime.app-init-ts.md](../experts/teams/runtime.app-init-ts.md) | +| Teams app manifest schema, scopes, bots/composeExtensions/staticTabs | [../experts/teams/runtime.manifest-ts.md](../experts/teams/runtime.manifest-ts.md) | +| Routing handlers (`app.on('message')`, activity types, invokes) | [../experts/teams/runtime.routing-handlers-ts.md](../experts/teams/runtime.routing-handlers-ts.md) | diff --git a/plugins/microsoft-365-agents-toolkit/skills/teams-app-developer/docs/README.md b/plugins/microsoft-365-agents-toolkit/skills/teams-app-developer/docs/README.md new file mode 100644 index 0000000..3cad2f3 --- /dev/null +++ b/plugins/microsoft-365-agents-toolkit/skills/teams-app-developer/docs/README.md @@ -0,0 +1,46 @@ +# Slack vs Teams: Platform Differences & Bridging Strategies + +A practical guide for developers adding cross-platform support to an existing bot. Each document covers a category of differences, explains why they matter, and provides concrete mitigation strategies with effort estimates. + +## Documents + +| Document | What It Covers | +|---|---| +| [**Feature Gaps**](feature-gaps.md) | **Complete inventory of every RED and YELLOW gap with mitigations in both directions** | +| [**Workflows**](workflows.md) | **Message-native workflow scenarios: standup, PTO, equipment, account health, break management, incidents** | +| [Messaging & Commands](messaging-and-commands.md) | Messages, slash commands, events, threading, @mentions | +| [UI Components](ui-components.md) | Block Kit vs Adaptive Cards, modals vs dialogs, App Home vs personal tabs | +| [Interactive Responses](interactive-responses.md) | Ephemeral messages, button actions, message updates, confirmation dialogs | +| [Identity & Auth](identity-and-auth.md) | User IDs, OAuth, signing/verification, tokens | +| [Files & Links](files-and-links.md) | File upload/download, link unfurling/previews | +| [Middleware & Handler Patterns](middleware-and-handlers.md) | Middleware chains, ack(), handler registration, error handling | +| [Advanced Features](advanced-features.md) | Scheduling, workflows, shortcuts, channel ops, reactions, distribution | +| [Infrastructure](infrastructure.md) | Transport, compute, storage, secrets, observability | +| [**Eval Harness**](../evals/README.md) | Automated testing for expert routing, completeness, and code patterns | + +## Eval Harness + +The [`evals/`](../evals/) directory contains an automated test harness for the expert system. It validates three dimensions: + +- **Routing** — 51 test cases across all 7 domains verify queries route to the correct domain, clusters, and expert files +- **Completeness** — 9 test cases check experts cover all required concepts for their domain +- **Patterns** — 294 TypeScript code blocks across all experts are compiled in-memory to catch syntax errors + +Pattern evals are fully deterministic (no API key needed). Routing and completeness evals use an LLM judge (OpenAI, Anthropic, or Azure OpenAI). See [`evals/README.md`](../evals/README.md) for setup and usage. + +## How to Read These Docs + +Each difference follows this format: + +- **What's different** — the concrete behavioral gap +- **Impact** — what breaks or degrades if you ignore it +- **Mitigation** — one or more strategies ranked by effort and fidelity +- **Effort** — rough hours to implement + +### Difficulty Ratings + +| Rating | Meaning | +|---|---| +| GREEN | Direct mapping exists. Mechanical conversion, minimal design decisions. | +| YELLOW | Mapping exists but requires design decisions or trade-offs. | +| RED | Platform gap — no equivalent exists. Requires redesign or custom workaround. | diff --git a/plugins/microsoft-365-agents-toolkit/skills/teams-app-developer/docs/advanced-features.md b/plugins/microsoft-365-agents-toolkit/skills/teams-app-developer/docs/advanced-features.md new file mode 100644 index 0000000..9650eb1 --- /dev/null +++ b/plugins/microsoft-365-agents-toolkit/skills/teams-app-developer/docs/advanced-features.md @@ -0,0 +1,149 @@ +# Advanced Features + +## Scheduled Messages + +| Aspect | Slack | Teams | +|---|---|---| +| Native API | `chat.scheduleMessage()` | **No equivalent** | +| Cancel scheduled | `chat.deleteScheduledMessage()` | N/A | +| Reminders | `reminders.add()` | **No equivalent** | + +**Rating:** RED (Slack → Teams), GREEN (Teams → Slack). + +### Mitigation Strategies (Slack → Teams) + +| Strategy | How | Effort | +|---|---|---| +| **Azure Functions timer + Cosmos DB (Recommended)** | Store scheduled message in Cosmos DB. Azure Functions timer trigger polls and sends via proactive messaging. | 16–24 hrs | +| **Azure Queue visibility timeout** | Set visibility timeout to delay message processing. 7-day maximum. | 8–12 hrs | +| **Azure Service Bus scheduled messages** | Best for high-volume exact-time delivery. | 12–16 hrs | +| **Power Automate** | Offload to Power Automate flows with "Delay until" action. Requires license. | 8–12 hrs | +| **In-process timer (dev only)** | `setTimeout` / `node-cron`. Not durable — lost on restart. | 2–4 hrs | + +### Reverse Direction (Teams → Slack) + +Use `chat.scheduleMessage()` and `reminders.add()` directly — native APIs. + +--- + +## Emoji Reactions + +| Aspect | Slack | Teams | +|---|---|---| +| Event | `reaction_added` / `reaction_removed` | `messageReaction` | +| Reaction types | Unlimited custom emoji | **6 fixed reactions only**: like, heart, laugh, surprised, sad, angry | +| Workflow use | Common to use reactions as workflow signals (e.g., `:white_check_mark:` = approved) | Not viable — too few options | + +**Rating:** RED (Slack → Teams) if reactions are used as workflow signals. + +### Mitigation (Slack → Teams) + +| Strategy | How | Effort | +|---|---|---| +| **Adaptive Card buttons (Recommended)** | Replace reaction-based workflows with `Action.Submit` buttons on cards (e.g., "Approve" / "Reject"). Better for audit trails. | 4–8 hrs | +| **Map to 6 fixed reactions** | Map your most important reactions to like/heart/laugh/surprised/sad/angry. Lossy — only works if you use ≤6 reactions. | 2–4 hrs | + +### Reverse Direction (Teams → Slack) + +Slack supports unlimited custom emoji reactions — direct mapping. + +--- + +## Shortcuts / Message Extensions + +| Aspect | Slack | Teams | +|---|---|---| +| Global shortcut | `app.shortcut("callback_id")` | Compose extension with `context: ["compose", "commandBox"]` | +| Message shortcut | `app.shortcut("callback_id")` (type: `message_shortcut`) | Action extension with `context: ["message"]` | +| Fire-and-forget | Supported (ack + background work) | **Not supported** — must open task module | +| Manifest config | Shortcut in app settings | `composeExtensions[].commands[]` | +| Message context | `shortcut.message` | `activity.value.messagePayload` | + +**Rating:** YELLOW — functional equivalents exist but UX differs. + +### Key Difference + +Slack shortcuts can run background actions without showing UI (ack + do work). Teams compose/action extensions always open a task module — there's no fire-and-forget pattern. Use a "minimal dismiss" pattern: return a tiny "Done" card that auto-closes. + +### Mitigation (Slack → Teams) + +| Strategy | How | Effort | +|---|---|---| +| **Compose extension (Recommended)** | `composeExtensions` with `commandBox` context. Opens task module. | 8–12 hrs | +| **Minimal-dismiss pattern** | Task module returns tiny "Done" card for fire-and-forget actions. | 4–8 hrs | +| **Bot command replacement** | Replace shortcut with typed command. Simpler but less discoverable. | 2–4 hrs | + +--- + +## Channel Operations + +| Aspect | Slack | Teams | +|---|---|---| +| Create channel | `conversations.create()` | Graph `POST /teams/{team-id}/channels` | +| Archive channel | `conversations.archive()` | **No equivalent** — Teams can only archive entire Teams | +| Set topic | `conversations.setTopic()` | Graph `PATCH /channels/{id}` with `description` | +| Invite member | `conversations.invite()` | Graph `POST /channels/{id}/members` (one call per member) | +| Remove member | `conversations.kick()` | Graph `DELETE /channels/{id}/members/{membership-id}` (must resolve membership ID first) | +| Channel namespace | Flat (channel ID is globally unique) | Team-scoped (need `team-id` + `channel-id`) | +| Channel name limits | 80 chars, most characters allowed | 50 chars, no special characters | + +**Rating:** GREEN for create/topic/invite, YELLOW for remove (membership ID resolution), RED for archive. + +### Archive Mitigation (Slack → Teams) + +| Strategy | How | Effort | +|---|---|---| +| **Rename with [ARCHIVED] prefix (Recommended)** | Rename channel, update description. Cosmetic but non-destructive. | 4–8 hrs | +| **Rename + remove all members** | Stronger enforcement but destructive — members must be re-invited to undo. | 8–12 hrs | +| **Team-level archive** | Archive entire Team. Only works if channel is in a dedicated Team. | 2–4 hrs | + +--- + +## Workflows / Automation + +| Aspect | Slack | Teams | +|---|---|---| +| Platform | Workflow Builder (free) | Power Automate (licensed for premium connectors) | +| Bot integration | `workflow_step_execute` event | Custom connectors or bot-driven orchestration | +| Triggers | Channel message, emoji reaction, scheduled, webhook | Same + Approvals connector, Planner, SharePoint | +| Migration tool | N/A | **None** — manual rebuild required | + +**Rating:** YELLOW — functional equivalent exists but different platform, possible licensing. + +### Mitigation Strategies + +| Strategy | How | Effort | +|---|---|---| +| **Bot-driven orchestration (Recommended)** | Keep workflow logic in the bot. State machine + Adaptive Card buttons + persistent storage. No license dependency. | 16–40 hrs | +| **Power Automate rebuild** | Rebuild in Power Automate. Custom steps need Premium license. | 24–80 hrs | +| **Hybrid** | Simple flows → Power Automate, complex → bot-driven. | Varies | +| **Teams Workflows app** | Simplified UI for basic automations (free). Limited to simple scenarios. | 4–8 hrs | + +--- + +## App Distribution + +| Aspect | Slack | Teams | +|---|---|---| +| Directory listing | Slack App Directory (api.slack.com) | Teams App Store via Partner Center | +| Review time | Hours to days | 1–2 weeks | +| Org-level install | Workspace admin approval | Teams Admin Center tenant-wide deployment | +| Dev install | Direct install via OAuth URL | Sideloading (ZIP with manifest + icons) | +| Required assets | App icon | 192x192 full-color icon + 32x32 monochrome outline | +| Multi-tenant | Per-workspace tokens via `InstallationStore` | `signInAudience: "AzureADMultipleOrgs"` in Azure AD | + +**Rating:** YELLOW — both have distribution mechanisms but packaging and review differ. + +### Sideloading (Dev/Test) + +Teams sideloading requires: +1. `manifest.json` (schema v1.19+) +2. `color.png` (192x192) +3. `outline.png` (32x32 monochrome) +4. ZIP all three files +5. Upload via Teams client → Apps → Manage your apps → Upload +6. Note: Sideloading may be disabled by admin — check tenant settings + +### Reverse Direction (Teams → Slack) + +Submit to Slack App Directory via api.slack.com. Implement `InstallProvider` for OAuth install flow. Shorter review cycle. diff --git a/plugins/microsoft-365-agents-toolkit/skills/teams-app-developer/docs/feature-gaps.md b/plugins/microsoft-365-agents-toolkit/skills/teams-app-developer/docs/feature-gaps.md new file mode 100644 index 0000000..ad27e6c --- /dev/null +++ b/plugins/microsoft-365-agents-toolkit/skills/teams-app-developer/docs/feature-gaps.md @@ -0,0 +1,1120 @@ +# Feature Gap Analysis: Slack ↔ Teams + +A complete inventory of every feature that does **not** have a direct equivalent on the other platform, organized by severity. Each gap includes mitigations in both directions. + +## How to Read This Document + +- **Slack → Teams** = you have a Slack bot and are adding Teams support +- **Teams → Slack** = you have a Teams bot and are adding Slack support +- Effort estimates are per-feature implementation hours +- Features with direct 1:1 mappings (GREEN) are not listed — see [messaging-and-commands.md](messaging-and-commands.md) and [ui-components.md](ui-components.md) for those + +--- + +## RED Gaps — No Platform Equivalent + +These features exist on one platform with **no counterpart** on the other. They require redesign, custom infrastructure, or acceptance of reduced functionality. + +--- + +### R1. Ephemeral Messages + +**Slack has it. Teams does not.** + +Slack's `chat.postEphemeral()` sends a message visible only to one user in a channel. Teams has no visibility flag — all bot messages are visible to everyone. + +| Strategy | Direction | How | Effort | +|---|---|---|---| +| `refresh.userIds` on `Action.Execute` | Slack → Teams | Card shows different content per user. Covers ~80% of cases. Max 60 user IDs per card. | 4–8 hrs | +| Route to 1:1 chat | Slack → Teams | Send private content to user's personal bot chat via proactive messaging. Different UX but reliable. | 2–4 hrs | +| Build `sendEphemeral()` helper | Slack → Teams | Wrapper that auto-detects context and picks the best strategy. Worth it if many handlers use ephemeral. | 8–12 hrs | +| Drop ephemeral behavior | Slack → Teams | Show messages to everyone. Simplest but may expose private data. | 0 hrs | +| **Native `chat.postEphemeral()`** | **Teams → Slack** | **Direct API call. No gap in this direction.** | **0 hrs** | + +--- + +### R2. Custom Emoji Reactions + +**Slack has it. Teams does not.** + +Slack supports unlimited custom emoji as reactions. Teams supports exactly 6 fixed reactions: like, heart, laugh, surprised, sad, angry. Bots that use reactions as workflow signals (`:white_check_mark:` = approved) cannot map to Teams. + +| Strategy | Direction | How | Effort | +|---|---|---|---| +| Adaptive Card buttons | Slack → Teams | Replace reaction workflows with `Action.Submit` buttons (e.g., "Approve" / "Reject"). Better audit trail. | 4–8 hrs | +| Map to 6 fixed reactions | Slack → Teams | Map most important reactions to like/heart/laugh/surprised/sad/angry. Lossy — only works with ≤6 reactions. | 2–4 hrs | +| **Native emoji reactions** | **Teams → Slack** | **Direct mapping. Slack supports unlimited custom emoji.** | **0 hrs** | + +--- + +### R3. Modal Cancel Notification (`viewClosed`) + +**Slack has it. Teams does not.** + +Slack fires `view_closed` when a user dismisses a modal (with `notify_on_close: true`). Teams sends no notification when a dialog is dismissed — the bot never knows the user cancelled. + +| Strategy | Direction | How | Effort | +|---|---|---|---| +| Timeout + explicit Cancel button | Slack → Teams | Add a "Cancel" button inside the dialog. Implement 5-min TTL for cleanup of stale locks/state. | 4–8 hrs | +| Accept stale state | Slack → Teams | Drop cancel cleanup. Accept that some locks may persist until TTL. | 0 hrs | +| **Native `notify_on_close: true`** | **Teams → Slack** | **Set `notify_on_close: true` in `views.open()`. Native support.** | **0 hrs** | + +--- + +### R4. Mid-Form Dynamic Updates + +**Slack has it. Teams does not.** + +Slack modals support `dispatch_action: true` on inputs, which fires `block_actions` events while the modal is open. The bot can then call `views.update()` to change the modal dynamically (e.g., show/hide fields based on a dropdown selection). Teams dialogs have no equivalent — Adaptive Card inputs don't fire events until the form is submitted. + +| Strategy | Direction | How | Effort | +|---|---|---|---| +| Multi-step dialogs | Slack → Teams | Split dependent fields across dialog steps. Step 1 collects the trigger value; step 2 shows dependent fields. | 8–16 hrs | +| `Action.ToggleVisibility` | Slack → Teams | Show/hide elements client-side. Works for simple show/hide but cannot fetch server data. | 2–4 hrs | +| Web-based task module | Slack → Teams | Embed a full web form in an iframe with real-time interactivity. Full control but much more effort. | 16–24 hrs | +| **Native `block_actions` + `views.update()`** | **Teams → Slack** | **Set `dispatch_action: true` on input elements. Handle `block_actions` and call `views.update()`.** | **2–4 hrs** | + +--- + +### R5. Server-Side Field Validation with Inline Errors + +**Slack has it. Teams does not.** + +Slack's `view_submission` handler can return `response_action: "errors"` with a map of `{ block_id: "error message" }` to show inline validation errors without closing the modal. Teams dialogs close on submit — there is no way to keep the dialog open with error messages. + +| Strategy | Direction | How | Effort | +|---|---|---|---| +| Re-open dialog with errors | Slack → Teams | On validation failure, return a new dialog card pre-populated with the user's data and error messages in field labels. | 4–8 hrs | +| Client-side validation only | Slack → Teams | Use Adaptive Card `isRequired`, `regex`, `maxLength`, `min`/`max`. Covers simple cases but not async checks (e.g., "username taken"). | 1–2 hrs | +| **Native `response_action: "errors"`** | **Teams → Slack** | **Return `{ response_action: "errors", errors: { block_id: "msg" } }` from `view_submission` handler.** | **0 hrs** | + +--- + +### R6. Dialog / Modal Stacking + +**Slack has it. Teams does not.** + +Slack supports `views.push()` to stack up to 3 modals. The user can navigate back by dismissing the top modal. Teams dialogs do not stack — opening a new dialog replaces the current one. + +| Strategy | Direction | How | Effort | +|---|---|---|---| +| Single dialog with step routing | Slack → Teams | One dialog with internal step state. Submit handler checks step number and returns the next step's card. Add a "Back" button that decrements the step. | 8–16 hrs | +| Build `StepDialog` helper | Slack → Teams | Reusable class managing step state, forward/back navigation. Worth it if 3+ wizard flows exist. | 16–24 hrs | +| Sequential separate dialogs | Slack → Teams | Close current dialog, open next. No back navigation. Degraded UX. | 4–8 hrs | +| **Native `views.push()`** | **Teams → Slack** | **Call `views.push()` from within a `view_submission` or `block_actions` handler. Up to 3 levels.** | **0 hrs** | + +--- + +### R7. Scheduled Message API + +**Slack has it. Teams does not.** + +Slack provides `chat.scheduleMessage()` and `chat.deleteScheduledMessage()` as first-class APIs. Teams has no server-side scheduling — the bot must build its own. + +| Strategy | Direction | How | Effort | +|---|---|---|---| +| Azure Functions timer + Cosmos DB | Slack → Teams | Store message + target time in DB. Timer function polls every minute and sends via proactive messaging. | 16–24 hrs | +| Azure Queue visibility timeout | Slack → Teams | Enqueue with `visibilityTimeout` set to the delay. Queue trigger fires at the right time. 7-day max. | 8–12 hrs | +| Azure Service Bus scheduled messages | Slack → Teams | `scheduleMessages(msg, scheduledTime)`. Exact-time delivery, native cancellation. Best for high volume. | 12–16 hrs | +| Power Automate | Slack → Teams | "Delay until" action in a flow. No code but requires license for custom connectors. | 8–12 hrs | +| In-process timer (dev only) | Slack → Teams | `setTimeout` / `node-cron`. Not durable — lost on restart. | 2–4 hrs | +| **Native `chat.scheduleMessage()`** | **Teams → Slack** | **Direct API call with `post_at` Unix timestamp. Native cancellation via `deleteScheduledMessage()`.** | **0 hrs** | + +--- + +### R8. Channel Archive + +**Slack has it. Teams does not.** + +Slack's `conversations.archive()` archives a channel — it becomes read-only and hidden from the channel list. Teams can only archive an entire Team, not individual channels. + +| Strategy | Direction | How | Effort | +|---|---|---|---| +| Rename with `[ARCHIVED]` prefix | Slack → Teams | Rename channel, update description to "Archived on {date}". Non-destructive. Cosmetic only. | 4–8 hrs | +| Rename + remove all members | Slack → Teams | Rename + kick everyone. Stronger enforcement but destructive and hard to undo. | 8–12 hrs | +| Team-level archive | Slack → Teams | Archive the entire Team via Graph. Only works if the channel has a dedicated Team. | 2–4 hrs | +| **Native `conversations.archive()`** | **Teams → Slack** | **Direct API call. Reversible via `conversations.unarchive()`.** | **0 hrs** | + +--- + +### R9. Retroactive Link Unfurling + +**Slack has it. Teams does not.** + +Slack unfurls links in existing messages (edited to add a link, or links posted before the bot was installed). Teams only unfurls links in new messages — editing a message to add a link does not trigger unfurling. + +| Strategy | Direction | How | Effort | +|---|---|---|---| +| **Accept the limitation (Recommended)** | Slack → Teams | No workaround exists. New message unfurling works fine. | 0 hrs | +| Manual preview command | Slack → Teams | Bot command where users paste a URL to get a preview card. Niche use case. | 4–8 hrs | +| **Native retroactive unfurling** | **Teams → Slack** | **Slack unfurls retroactively by default. No issue.** | **0 hrs** | + +--- + +### R10. Firewall-Friendly Transport (Socket Mode) + +**Slack has it. Teams does not.** + +Slack's Socket Mode uses an outbound WebSocket — no inbound ports needed. The bot can run behind any firewall. Teams requires a public HTTPS endpoint for inbound webhooks. + +| Strategy | Direction | How | Effort | +|---|---|---|---| +| Deploy to Azure | Slack → Teams | Host in App Service / Functions / Container Apps. Use Dev Tunnels for local dev. Standard cloud deployment. | 4–8 hrs | +| Azure Relay | Slack → Teams | Hybrid connection for strict on-premises firewalls that cannot expose any public endpoint. Adds latency. | 8–16 hrs | +| **Native Socket Mode** | **Teams → Slack** | **Set `socketMode: true` with `appToken`. Outbound WebSocket, zero inbound ports.** | **1–2 hrs** | + +--- + +## RED Gap Workarounds + +Detailed implementation patterns for every RED gap. These are the recommended approaches — pick the one that fits your bot's needs. + +--- + +### R1 Workaround: Ephemeral via `refresh.userIds` + +The best general-purpose workaround. An `Action.Execute` card with `refresh.userIds` shows personalized content to specific users while showing a default card to everyone else. + +```typescript +// Teams: per-user card content (replaces chat.postEphemeral) +const card = { + type: "AdaptiveCard", + version: "1.4", + refresh: { + action: { + type: "Action.Execute", + verb: "personalView", + data: { requestId: "123" }, + }, + userIds: [actingUserId], // max 60 IDs + }, + body: [ + { type: "TextBlock", text: "A request was submitted." }, // everyone sees this + ], +}; + +// When the specified user views the card, Teams invokes the bot: +app.on("card.action", async (ctx) => { + if (ctx.activity.value?.action?.verb === "personalView") { + // Return a personalized card only this user sees + return { + status: 200, + body: { + type: "AdaptiveCard", + version: "1.4", + body: [ + { type: "TextBlock", text: "Your request #123 was approved.", weight: "Bolder" }, + { type: "TextBlock", text: "Only you can see these details." }, + ], + }, + }; + } +}); +``` + +**When this doesn't work:** More than 60 users need per-user views, or the content is plain text (not a card). Fall back to sending a proactive message in the user's 1:1 bot chat. + +**Reverse (Teams → Slack):** Use `chat.postEphemeral({ channel, user, text })` directly. Native support. + +--- + +### R2 Workaround: Reactions → Adaptive Card Buttons + +Replace emoji-reaction workflows with explicit card buttons. This actually improves auditability — button clicks are tracked, emoji reactions are not. + +```typescript +// Before (Slack): reaction-based approval +app.event("reaction_added", async ({ event, client }) => { + if (event.reaction === "white_check_mark") { + await client.chat.postMessage({ + channel: event.item.channel, + text: `Approved by <@${event.user}>`, + thread_ts: event.item.ts, + }); + } +}); + +// After (Teams): button-based approval +const approvalCard = { + type: "AdaptiveCard", version: "1.5", + body: [{ type: "TextBlock", text: "Request #42 needs approval" }], + actions: [ + { type: "Action.Submit", title: "Approve", style: "positive", + data: { action: "approve", requestId: "42" } }, + { type: "Action.Submit", title: "Reject", style: "destructive", + data: { action: "reject", requestId: "42" } }, + ], +}; +``` + +**When reactions are decorative** (not workflow signals): map to the 6 fixed Teams reactions. Only viable if you use ≤6 distinct reactions. + +**Reverse (Teams → Slack):** Map `Action.Submit` buttons to emoji reactions via `reactions.add`, or keep as Slack buttons (usually better UX anyway). + +--- + +### R3 Workaround: Cancel Detection via TTL + Explicit Button + +Since Teams sends no notification when a dialog is dismissed, combine two strategies: + +```typescript +// 1. Add an explicit Cancel button inside the dialog card +const dialogCard = { + type: "AdaptiveCard", version: "1.5", + body: [/* form fields */], + actions: [ + { type: "Action.Submit", title: "Submit", data: { action: "submit_form" } }, + { type: "Action.Submit", title: "Cancel", data: { action: "cancel_form", lockId: "abc" } }, + ], +}; + +// 2. Handle explicit cancel +app.on("dialog.submit", async ({ activity, send }) => { + const data = activity.value?.data; + if (data?.action === "cancel_form") { + await releaseLock(data.lockId); + return { status: 200, body: { task: { type: "message", value: "Cancelled." } } }; + } + // ... handle submit ... +}); + +// 3. TTL-based cleanup for users who close via the X button +setInterval(async () => { + const staleLocks = await getLocksOlderThan(5 * 60_000); // 5 min + for (const lock of staleLocks) await releaseLock(lock.id); +}, 60_000); +``` + +**Reverse (Teams → Slack):** Use `notify_on_close: true` in `views.open()` and handle `view_closed` callback. + +--- + +### R4 Workaround: Mid-Form Updates via Multi-Step Dialogs + +Split dependent fields across dialog steps. Step 1 collects the value that drives the dynamic behavior; step 2 renders the dependent fields. + +```typescript +app.on("dialog.submit", async ({ activity }) => { + const data = activity.value?.data; + + if (data?.step === 1) { + // User selected a category — return step 2 with dependent fields + const subcategories = await getSubcategories(data.category); + return { + status: 200, + body: { + task: { + type: "continue", + value: { + title: "Step 2 of 2", + card: buildStep2Card(data.category, subcategories), + }, + }, + }, + }; + } + + if (data?.step === 2) { + // Final submission + await processForm(data); + return { status: 200, body: { task: { type: "message", value: "Done!" } } }; + } +}); +``` + +**For simple show/hide** (no server data needed): use `Action.ToggleVisibility` to show/hide card elements client-side. This works for "show advanced options" toggles but cannot populate options from an API. + +**Reverse (Teams → Slack):** Use `dispatch_action: true` on inputs + `views.update()` in the `block_actions` handler. Native support for real-time form updates. + +--- + +### R5 Workaround: Server Validation via Dialog Re-render + +On validation failure, return a `continue` response with the same form, pre-populated with the user's values, plus error messages as colored `TextBlock` elements. + +```typescript +app.on("dialog.submit", async ({ activity }) => { + const data = activity.value?.data; + const errors: string[] = []; + + if (!data?.email?.includes("@")) errors.push("Invalid email address"); + if ((data?.name?.length ?? 0) < 2) errors.push("Name must be at least 2 characters"); + + if (errors.length > 0) { + return { + status: 200, + body: { + task: { + type: "continue", + value: { + title: "Fix Errors", + card: { + contentType: "application/vnd.microsoft.card.adaptive", + content: { + type: "AdaptiveCard", version: "1.5", + body: [ + // Error banner + ...errors.map(e => ({ + type: "TextBlock", text: e, color: "Attention", weight: "Bolder", + })), + // Re-populate form with user's previous values + { type: "Input.Text", id: "name", label: "Name", value: data.name ?? "" }, + { type: "Input.Text", id: "email", label: "Email", value: data.email ?? "" }, + ], + actions: [{ type: "Action.Submit", title: "Submit", data: { action: "register" } }], + }, + }, + }, + }, + }, + }; + } + + // Validation passed + await processRegistration(data); + return { status: 200, body: { task: { type: "message", value: "Registered!" } } }; +}); +``` + +**Combine with client-side validation** for the best UX: add `isRequired`, `regex`, and `errorMessage` to catch obvious errors before the server round-trip. + +**Reverse (Teams → Slack):** Use `response_action: "errors"` with `{ block_id: "error message" }` natively. + +--- + +### R6 Workaround: Modal Stacking via Step Routing + +Simulate `views.push` with a single dialog that routes by step number. Include a "Back" button that decrements the step. + +```typescript +app.on("dialog.submit", async ({ activity }) => { + const data = activity.value?.data; + const step = data?.step ?? 1; + + if (data?.action === "back") { + return continueDialog(buildStepCard(step - 1, data)); + } + + if (step < 3) { + return continueDialog(buildStepCard(step + 1, data)); + } + + // Final step — process all collected data + await processWizard(data); + return { status: 200, body: { task: { type: "message", value: "Complete!" } } }; +}); + +function continueDialog(card: object) { + return { + status: 200, + body: { task: { type: "continue", value: { title: `Step ${(card as any).step}`, card } } }, + }; +} + +function buildStepCard(step: number, previousData: Record): object { + // Each step card embeds ALL previous data in Action.Submit.data + // so nothing is lost between steps + return { + contentType: "application/vnd.microsoft.card.adaptive", + content: { + type: "AdaptiveCard", version: "1.5", + body: [/* step-specific fields */], + actions: [ + ...(step > 1 ? [{ type: "Action.Submit", title: "Back", + data: { ...previousData, step, action: "back" } }] : []), + { type: "Action.Submit", title: step === 3 ? "Finish" : "Next", + data: { ...previousData, step, action: "next" } }, + ], + }, + step, + }; +} +``` + +**Key principle:** Every step's `Action.Submit.data` must carry forward ALL data from previous steps, since there's no persistent modal state like Slack's `private_metadata`. + +**Reverse (Teams → Slack):** Use `views.push()` natively — up to 3 levels of stacking with built-in "X to go back" behavior. + +--- + +### R7 Workaround: Scheduling via Azure Service Bus + +The most production-ready approach. Azure Service Bus supports exact-time delivery and native cancellation. + +```typescript +import { ServiceBusClient } from "@azure/service-bus"; + +const sbClient = new ServiceBusClient(process.env.SERVICEBUS_CONNECTION!); +const sender = sbClient.createSender("scheduled-messages"); + +// Schedule a message +async function scheduleMessage( + conversationId: string, text: string, sendAt: Date +): Promise { + const [sequenceNumber] = await sender.scheduleMessages( + { body: { conversationId, text } }, + sendAt + ); + return sequenceNumber; // store this for cancellation +} + +// Cancel a scheduled message +async function cancelScheduled(sequenceNumber: Long): Promise { + await sender.cancelScheduledMessages(sequenceNumber); +} + +// Receiver (runs as a separate process or Azure Function) +const receiver = sbClient.createReceiver("scheduled-messages"); +receiver.subscribe({ + processMessage: async (msg) => { + const { conversationId, text } = msg.body; + await teamsApp.send(conversationId, text); + }, + processError: async (err) => console.error(err), +}); +``` + +**For simpler needs:** Azure Queue with `visibilityTimeout` (max 7 days) or Azure Functions timer + Cosmos DB (poll every minute). + +**Reverse (Teams → Slack):** Use `chat.scheduleMessage({ channel, text, post_at })` natively. + +--- + +### R8 Workaround: Channel Archive via Rename + Description + +The most widely used workaround. Cosmetic-only — doesn't actually prevent new messages. + +```typescript +async function archiveChannel( + graph: Client, teamId: string, channelId: string +): Promise { + const channel = await graph.api(`/teams/${teamId}/channels/${channelId}`).get(); + + await graph.api(`/teams/${teamId}/channels/${channelId}`).patch({ + displayName: `[ARCHIVED] ${channel.displayName}`.substring(0, 50), + description: `Archived on ${new Date().toISOString()}. Original: ${channel.description ?? ""}`, + }); +} +``` + +**For stronger enforcement:** After renaming, remove all non-owner members. This is destructive (members must be re-invited to undo) but prevents new messages. + +**Reverse (Teams → Slack):** Use `conversations.archive()` natively. Reversible via `conversations.unarchive()`. + +--- + +### R9 Workaround: Retroactive Unfurling + +**No workaround exists.** Teams only unfurls links in new messages. Accept this limitation — it affects a small percentage of use cases (links in edited messages or messages sent before the bot was installed). + +If critical, build a `/preview ` bot command that returns a card preview on demand. + +--- + +### R10 Workaround: Firewall Transport + +**Deploy to a cloud provider.** This is the standard path for any Teams bot. For local development, use Dev Tunnels (built into VS Code) or ngrok. + +For strict on-premises environments that truly cannot expose any endpoint, Azure Relay provides a hybrid connection where the bot connects outbound to Azure, and Azure proxies inbound Teams traffic through that connection. This adds 10–50ms latency but requires zero inbound firewall rules. + +**Reverse (Teams → Slack):** Enable Socket Mode with `socketMode: true` and `appToken`. Zero inbound ports, zero tunneling. + +--- + +## YELLOW Gaps — Equivalent Exists but Requires Design Decisions + +These features have functional equivalents on the other platform, but the mapping is not 1:1 and requires choosing an approach. + +--- + +### Y1. Slash Commands + +**Slack has native `/command`. Teams does not.** + +| Strategy | Direction | How | Effort | +|---|---|---|---| +| Text pattern matching | Slack → Teams | Detect command-like text in `app.on("message")`. Accept `weather` and `/weather`. | 2–4 hrs | +| Manifest bot commands | Slack → Teams | Add `commands[]` to manifest for Teams command menu. Not `/` prefix but discoverable. | 1–2 hrs | +| Message extension | Slack → Teams | `composeExtensions` for richer command UX with search results or task modules. | 8–12 hrs | +| **Native `app.command()`** | **Teams → Slack** | **Register via `app.command("/cmd", handler)`. Add `ack()` call. Configure in Slack app dashboard.** | **2–4 hrs** | + +--- + +### Y2. Thread Broadcast (`reply_broadcast`) + +**Slack has it as a single call. Teams requires two.** + +| Strategy | Direction | How | Effort | +|---|---|---|---| +| Two API calls | Slack → Teams | Call `reply()` (thread) + `send()` (channel) separately. | 1–2 hrs | +| `replyWithBroadcast()` wrapper | Slack → Teams | Convenience method that calls both internally. | 2–4 hrs | +| **Native `reply_broadcast: true`** | **Teams → Slack** | **Single `say()` call with `reply_broadcast: true`.** | **0 hrs** | + +--- + +### Y3. Thread Discovery + +**Slack has `conversations.replies()`. Teams uses Graph API.** + +| Strategy | Direction | How | Effort | +|---|---|---|---| +| Graph API direct | Slack → Teams | `GET /teams/{teamId}/channels/{channelId}/messages/{messageId}/replies`. Requires `ChannelMessage.Read.All`. | 4–8 hrs | +| `getThreadReplies()` helper | Slack → Teams | Wrapper encapsulating Graph client setup, auth, and pagination. | 8–12 hrs | +| **Native `conversations.replies()`** | **Teams → Slack** | **Direct API call with thread `ts`.** | **0 hrs** | + +--- + +### Y4/5/6. File Upload + +**Slack: one call. Teams: 3-step consent flow.** + +| Strategy | Direction | How | Effort | +|---|---|---|---| +| `sendFile()` helper | Slack → Teams | Unified wrapper: auto-detects personal/channel, routes to OneDrive/SharePoint, chunks >4 MB. | 24–40 hrs | +| Manual FileConsentCard | Slack → Teams | Implement 3-step consent flow directly. Verbose and error-prone. | 16–24 hrs | +| **Native `files.uploadV2()`** | **Teams → Slack** | **Single API call. No consent step.** | **1–2 hrs** | + +--- + +### Y7. Link Unfurling Deadline + +**Slack: 30-minute async. Teams: 5-second sync.** + +| Strategy | Direction | How | Effort | +|---|---|---|---| +| Cache-first with prefetch | Slack → Teams | Cache middleware wraps handler. Pre-populate for known URLs. Without this, slow unfurls silently fail. | 12–16 hrs | +| Synchronous handler only | Slack → Teams | Direct handler. Only viable for fast data sources (<5 seconds). | 4–8 hrs | +| **Native async `chat.unfurl()`** | **Teams → Slack** | **Handle `link_shared` event. Respond within 30 minutes via `chat.unfurl()`.** | **2–4 hrs** | + +--- + +### Y8. Reminders + +**Slack has `reminders.add()`. Teams does not.** + +| Strategy | Direction | How | Effort | +|---|---|---|---| +| Piggyback on scheduler (R7) | Slack → Teams | Reuse scheduled message infrastructure. `setReminder()` stores + sends to 1:1 chat at target time. | 4–8 hrs (if scheduler exists) | +| Power Automate + Planner | Slack → Teams | Create Planner tasks with due-date notifications. | 8–12 hrs | +| **Native `reminders.add()`** | **Teams → Slack** | **Direct API call. Platform-managed delivery.** | **0 hrs** | + +--- + +### Y9. Dynamic Select Menus (Server-Side Typeahead) + +**Slack has `external_data_source` + `block_suggestion`. Teams does not.** + +Slack's `app.options()` handler receives keystrokes and returns filtered results from the server. Teams' `Input.ChoiceSet` is client-side only. + +| Strategy | Direction | How | Effort | +|---|---|---|---| +| Pre-populated `Input.ChoiceSet` | Slack → Teams | Load all options at dialog open. Client-side filtering via `style: "filtered"`. Works up to ~500 items. | 2–4 hrs | +| Two-step dialog | Slack → Teams | Step 1: text input for search. Step 2: filtered results as `ChoiceSet`. Works for any dataset size. | 8–12 hrs | +| Web-based task module | Slack → Teams | Embed a web view with search-as-you-type. Full control. High effort. | 16–24 hrs | +| **Native `block_suggestion`** | **Teams → Slack** | **Set `external_data_source: true` on select. Handle `app.options()` for server-side filtering.** | **2–4 hrs** | + +--- + +### Y10. App Home + +**Slack has `app_home_opened` + `views.publish()`. Teams uses tabs.** + +| Strategy | Direction | How | Effort | +|---|---|---|---| +| `tab.fetch` handler | Slack → Teams | Personal tab returns Adaptive Card on every open. Closest to `app_home_opened`. | 4–8 hrs | +| Welcome card on install | Slack → Teams | Send card to 1:1 chat on `install.add`. Simple but fires once. | 1–2 hrs | +| Static web tab | Slack → Teams | Full web page in iframe. Richer but needs hosting + Teams JS SDK. | 8–16 hrs | +| **Native `views.publish()`** | **Teams → Slack** | **Listen for `app_home_opened` event. Call `views.publish()` with Block Kit.** | **2–4 hrs** | + +--- + +### Y11. View Hash (Race Condition Protection) + +**Slack has `view_hash`. Teams does not.** + +Slack's `views.update()` accepts a `view_hash` parameter. If the view has changed since the hash was captured, the update is rejected. This prevents race conditions. Teams has no equivalent. + +| Strategy | Direction | How | Effort | +|---|---|---|---| +| Manual `_version` field | Slack → Teams | Inject version counter into `Action.Submit.data`. Reject updates where the submitted version doesn't match the stored version. | 2–4 hrs | +| Card versioning middleware | Slack → Teams | SDK plugin auto-injecting and checking version counters on every card send/receive. | 4–8 hrs | +| **Native `view_hash`** | **Teams → Slack** | **Pass `view_hash` from the previous `views.open()` / `views.update()` response.** | **0 hrs** | + +--- + +### Y12. Global Shortcuts + +**Slack has `app.shortcut()` (global). Teams uses compose extensions.** + +| Strategy | Direction | How | Effort | +|---|---|---|---| +| Compose extension | Slack → Teams | `composeExtensions` with `context: ["compose", "commandBox"]`. Always opens task module. | 8–12 hrs | +| Minimal-dismiss pattern | Slack → Teams | Task module returns tiny "Done" card for fire-and-forget actions. | 4–8 hrs | +| Bot command | Slack → Teams | Replace shortcut with typed command. Simpler but less discoverable. | 2–4 hrs | +| **Native `app.shortcut()`** | **Teams → Slack** | **Register global shortcut callback. Can fire-and-forget (ack + background work).** | **2–4 hrs** | + +--- + +### Y13. Message Shortcuts + +**Slack has `message_shortcut`. Teams uses action-based message extensions.** + +| Strategy | Direction | How | Effort | +|---|---|---|---| +| Action message extension | Slack → Teams | `composeExtensions` command with `context: ["message"]`. Message payload in `activity.value.messagePayload`. | 4–8 hrs | +| **Native `message_shortcut`** | **Teams → Slack** | **Register `app.shortcut()` with type `message_shortcut`. Message in `shortcut.message`.** | **2–4 hrs** | + +--- + +### Y14. Confirmation Dialogs on Buttons + +**Slack has native `confirm` object. Teams does not.** + +| Strategy | Direction | How | Effort | +|---|---|---|---| +| `Action.ShowCard` inline | Slack → Teams | Inline expand with "Are you sure?" + Yes/No buttons. Native Adaptive Card. | 2–4 hrs | +| Task module confirm | Slack → Teams | Small dialog popup. More prominent, closer to Slack UX. | 4–6 hrs | +| `confirmAction()` helper | Slack → Teams | Template function generating confirm cards. Reusable. | 4–8 hrs | +| **Native `confirm` object** | **Teams → Slack** | **Add `confirm` object to button element. Platform-rendered popup.** | **0 hrs** | + +--- + +### Y15. Unfurl Domain Wildcards + +**Slack supports `*.example.com`. Teams requires exact domain listing.** + +| Strategy | Direction | How | Effort | +|---|---|---|---| +| Manual enumeration | Slack → Teams | List every subdomain in manifest `domains[]`. Fine for <10. | 1–2 hrs | +| Manifest generator script | Slack → Teams | Script reads subdomain list from config and generates manifest array. | 4–8 hrs | +| **Native wildcard support** | **Teams → Slack** | **Wildcards work out of the box.** | **0 hrs** | + +--- + +### Y16. All Channel Messages Without @Mention + +**Slack gets them by default. Teams requires RSC permission.** + +| Strategy | Direction | How | Effort | +|---|---|---|---| +| RSC permission | Slack → Teams | Add `ChannelMessage.Read.Group` to manifest `webApplicationInfo.applicationPermissions`. Config only. | 1–2 hrs | +| Require @mention | Slack → Teams | Change UX to require @mention. Simplifies permissions but changes behavior. | 0 hrs | +| **Default behavior** | **Teams → Slack** | **Slack bots receive all messages in channels they're added to. No config needed.** | **0 hrs** | + +--- + +### Y17. Built-in Retry / Resilience + +**Slack Bolt has `retryConfig`. Teams SDK has no built-in retry.** + +| Strategy | Direction | How | Effort | +|---|---|---|---| +| Build `RetryPlugin` | Slack → Teams | Plugin with exponential backoff, jitter, circuit breaker. | 12–16 hrs | +| Manual retry wrapper | Slack → Teams | Hand-roll backoff around outbound calls. Simpler but easy to get wrong. | 4–8 hrs | +| **Native Bolt `retryConfig`** | **Teams → Slack** | **Configure in `App` constructor. Built-in exponential backoff.** | **0 hrs** | + +--- + +### Y18. Workflow Builder + +**Slack has it (free). Teams uses Power Automate (licensed).** + +| Strategy | Direction | How | Effort | +|---|---|---|---| +| Bot-driven orchestration | Slack → Teams | State machine + Adaptive Card buttons + persistent storage. No license dependency. | 16–40 hrs | +| Power Automate rebuild | Slack → Teams | Rebuild in Power Automate. Custom steps need Premium license. | 24–80 hrs | +| Teams Workflows app | Slack → Teams | Simplified UI for basic automations (free). Limited scenarios. | 4–8 hrs | +| Hybrid | Slack → Teams | Simple flows → Power Automate, complex → bot-driven. | Varies | +| **Native Workflow Builder** | **Teams → Slack** | **Rebuild in Slack Workflow Builder. Free, no license.** | **8–16 hrs** | + +--- + +### Y19. App Distribution + +**Both platforms have app stores, but packaging and review differ.** + +| Strategy | Direction | How | Effort | +|---|---|---|---| +| Org app catalog | Slack → Teams | Publish to organization catalog via Teams Admin Center. Requires admin approval. | 2–4 hrs | +| Sideloading | Slack → Teams | ZIP manifest + icons. Upload via Teams client. May be disabled by admin. | 1–2 hrs | +| Partner Center (public) | Slack → Teams | Submit to Teams App Store. 1–2 week review. Requires Partner Network account. | 8–16 hrs | +| **Slack App Directory** | **Teams → Slack** | **Submit via api.slack.com. Hours-to-days review. Implement `InstallProvider` for OAuth install flow.** | **4–8 hrs** | + +--- + +--- + +## YELLOW Gap Best Practices + +Recommended approaches for every YELLOW gap. These are the patterns that produce the best cross-platform UX with the least effort. + +--- + +### Y1. Slash Commands — Best Practice + +**Use text pattern matching + manifest bot commands together.** + +Register commands in the Teams manifest for discoverability (users see them in the command menu), AND detect them via text pattern matching as a fallback. Accept both `/weather` and `weather` so users migrating from Slack don't have to retrain muscle memory. + +```typescript +// Teams: detect both patterns +app.message(/^\/?weather$/i, async (ctx) => { + const response = await handleWeather(); + await ctx.send(response); +}); +``` + +In the Teams manifest: +```json +{ "commands": [{ "title": "weather", "description": "Check the weather" }] } +``` + +**Don't:** Create a message extension for every slash command. Reserve extensions for commands that benefit from rich search results or task module UI. + +--- + +### Y2. Thread Broadcast — Best Practice + +**Write a one-line helper that makes both calls.** Don't over-engineer this. + +```typescript +async function replyWithBroadcast(ctx: any, text: string): Promise { + await ctx.reply(text); + await ctx.send(text); +} +``` + +**Don't:** Try to batch these into a single API call — Teams doesn't support it. Two calls is the correct pattern. + +--- + +### Y3. Thread Discovery — Best Practice + +**Use Graph API directly with the `ctx.appGraph` client.** Don't build a wrapper unless you need pagination across multiple threads. + +```typescript +const replies = await ctx.appGraph + .api(`/teams/${teamId}/channels/${channelId}/messages/${messageId}/replies`) + .top(50) + .get(); +``` + +**Watch out for:** `ChannelMessage.Read.All` is an application permission requiring admin consent. If you only need thread replies in the bot's own conversations, you may be able to use delegated permissions instead. + +--- + +### Y4/5/6. File Upload — Best Practice + +**Build the `sendFile()` helper.** The manual FileConsentCard flow is a 30-line footgun that's easy to get wrong. A helper that auto-detects personal vs. channel context and handles chunking for large files pays for itself after the second use. + +**Key decisions:** +- Personal chat → FileConsentCard flow (requires `supportsFiles: true` in manifest) +- Channel → Direct Graph API upload to SharePoint (no consent card) +- Files >4 MB → Graph resumable upload session with 320 KB–60 MB chunks + +**Don't:** Store pending file buffers in memory for long periods. Upload promptly or stream to a temporary blob. + +--- + +### Y7. Link Unfurling — Best Practice + +**Always use a cache layer.** The 5-second Teams deadline makes this non-optional. Cache aggressively: + +1. On first unfurl, fetch and cache the preview data +2. Set a reasonable TTL (5–60 minutes depending on data freshness needs) +3. For known high-traffic URLs, pre-populate the cache on startup + +```typescript +const cache = new Map(); + +app.on("message.ext.query-link", async ({ activity }) => { + const url = activity.value?.url; + const cached = cache.get(url); + if (cached && cached.expires > Date.now()) { + return buildUnfurlResponse(cached.data); + } + const data = await fetchPreviewData(url); // must complete in <4 seconds + cache.set(url, { data, expires: Date.now() + 300_000 }); // 5 min TTL + return buildUnfurlResponse(data); +}); +``` + +**Don't:** Make multiple API calls in the unfurl handler. Pre-fetch or batch data sources. + +--- + +### Y8. Reminders — Best Practice + +**Piggyback on whatever scheduling infrastructure you built for R7.** Don't create a separate system. A reminder is just a scheduled message sent to a 1:1 conversation. + +```typescript +async function setReminder(userId: string, text: string, when: Date): Promise { + const conversationId = await get1to1ConversationId(userId); + await scheduleMessage(conversationId, `Reminder: ${text}`, when); +} +``` + +**Don't:** Use Power Automate + Planner for bot reminders — it adds an external dependency and licensing complexity. Keep it in the bot. + +--- + +### Y9. Dynamic Select Menus — Best Practice + +**Pre-populate with `Input.ChoiceSet` `style: "filtered"` for datasets under 500 items.** This covers the vast majority of cases (user lists, category selects, project dropdowns). + +```json +{ + "type": "Input.ChoiceSet", + "id": "user_select", + "label": "Assign to", + "style": "filtered", + "choices": [ + { "title": "Alice Smith", "value": "alice@company.com" }, + { "title": "Bob Jones", "value": "bob@company.com" } + ] +} +``` + +**For datasets over 500 items:** Use a two-step dialog. Step 1 is a text input for search. The submit handler queries the server and returns step 2 with filtered results as a `ChoiceSet`. + +**Don't:** Build a web-based task module just for a searchable dropdown. The effort (16–24 hrs) rarely justifies the marginal UX improvement over two-step. + +--- + +### Y10. App Home — Best Practice + +**Use `tab.fetch` to return an Adaptive Card.** It fires on every tab open (like `app_home_opened`) and supports `tab.submit` for interactions within the tab. + +```typescript +app.on("tab.fetch", async (ctx) => { + const userData = await getUserDashboard(ctx.activity.from?.aadObjectId ?? ""); + return { + status: 200, + body: { + tab: { + type: "continue", + value: { cards: [{ card: buildDashboardCard(userData) }] }, + }, + }, + }; +}); +``` + +**Don't:** Use a static web tab unless you need rich interactivity beyond what Adaptive Cards can provide (charts, real-time updates, complex navigation). Web tabs require hosting, CORS configuration, and the Teams JS SDK. + +--- + +### Y11. View Hash — Best Practice + +**Inject a `_version` counter into every card's `Action.Submit.data`.** Increment on every update. Reject submissions where the version doesn't match. + +```typescript +let cardVersion = 0; + +function buildCard(data: any): object { + cardVersion++; + return { + type: "AdaptiveCard", version: "1.5", + body: [/* ... */], + actions: [{ + type: "Action.Submit", title: "Update", + data: { ...data, _version: cardVersion }, + }], + }; +} + +app.on("card.action", async (ctx) => { + const submitted = ctx.activity.value?.action?.data; + if (submitted?._version !== cardVersion) { + await ctx.send("This card is outdated. Please use the latest version."); + return; + } + // Process the update... +}); +``` + +**Don't:** Skip version checking for low-traffic bots — race conditions happen even with single users (fast double-clicks, multiple tabs). + +--- + +### Y12. Global Shortcuts — Best Practice + +**Use compose extensions for actions that open a form.** For fire-and-forget actions (no UI), use the minimal-dismiss pattern: return a tiny "Done" card that auto-closes. + +```json +{ + "composeExtensions": [{ + "commands": [{ + "id": "quickAction", + "type": "action", + "title": "Quick Action", + "context": ["compose", "commandBox"], + "fetchTask": true + }] + }] +} +``` + +**Don't:** Replace every shortcut with a bot command. Commands are less discoverable than compose extensions, which appear in the Teams UI with icons and descriptions. + +--- + +### Y13. Message Shortcuts — Best Practice + +**Use action-based message extensions with `context: ["message"]`.** This is the closest 1:1 mapping to Slack's message shortcuts. + +Access the original message via `activity.value.messagePayload` — it contains the message text, sender, and timestamp. + +**Don't:** Forget to add `fetchTask: true` in the manifest command. Without it, the extension silently does nothing when clicked. + +--- + +### Y14. Confirmation Dialogs — Best Practice + +**Use `Action.ShowCard` for inline confirmation.** It expands inline without leaving the current context — closest to Slack's native `confirm` popup. + +```json +{ + "type": "Action.ShowCard", + "title": "Delete", + "card": { + "type": "AdaptiveCard", + "body": [{ "type": "TextBlock", "text": "Are you sure you want to delete this?", "weight": "Bolder" }], + "actions": [ + { "type": "Action.Submit", "title": "Yes, delete", "style": "destructive", + "data": { "action": "confirm_delete", "itemId": "42" } }, + { "type": "Action.Submit", "title": "Cancel", + "data": { "action": "cancel_delete" } } + ] + } +} +``` + +**Don't:** Open a full task module dialog for a simple yes/no confirmation. It's too heavy for the interaction. + +--- + +### Y15. Unfurl Domain Wildcards — Best Practice + +**Enumerate domains in the manifest.** For fewer than 10 subdomains, list them manually. For more, write a build-time script that reads your subdomain list and generates the manifest array. + +```json +{ + "composeExtensions": [{ + "messageHandlers": [{ + "type": "link", + "value": { + "domains": [ + "app.example.com", + "docs.example.com", + "api.example.com" + ] + } + }] + }] +} +``` + +**Don't:** Try to register a single wildcard domain — Teams will reject it silently. + +--- + +### Y16. All Channel Messages — Best Practice + +**Add the RSC permission to the manifest.** It's config-only, no code change, and matches Slack's default behavior. + +```json +{ + "webApplicationInfo": { + "id": "{{CLIENT_ID}}", + "resource": "api://{{CLIENT_ID}}" + }, + "authorization": { + "permissions": { + "resourceSpecific": [ + { "name": "ChannelMessage.Read.Group", "type": "Application" } + ] + } + } +} +``` + +Also set `activity.mentions.stripText: true` in the App constructor to remove `bot` text from messages that do include an @mention. + +**Don't:** Change your UX to require @mention unless your bot genuinely shouldn't listen to all messages. + +--- + +### Y17. Built-in Retry — Best Practice + +**Build a retry utility with exponential backoff and jitter.** Apply it to all outbound API calls (Graph, proactive messaging, external services). + +```typescript +async function withRetry(fn: () => Promise, maxRetries = 3): Promise { + for (let attempt = 0; attempt <= maxRetries; attempt++) { + try { + return await fn(); + } catch (err: any) { + if (attempt === maxRetries) throw err; + const retryAfter = err?.response?.headers?.["retry-after"]; + const baseDelay = retryAfter ? parseInt(retryAfter) * 1000 : 1000 * 2 ** attempt; + const jitter = Math.random() * 1000; + await new Promise(r => setTimeout(r, baseDelay + jitter)); + } + } + throw new Error("Unreachable"); +} +``` + +**For proactive broadcasts** (sending to hundreds of users): use `p-queue` with concurrency control (e.g., 5 concurrent sends) to stay within Teams' rate limits (~1 msg/sec/conversation). + +**Don't:** Retry without jitter. Without random delay, multiple bot instances retry at the same time and cause a thundering herd. + +--- + +### Y18. Workflow Builder — Best Practice + +**Keep workflow logic in the bot (bot-driven orchestration).** This avoids Power Automate licensing dependencies and keeps everything in one codebase. + +Pattern: state machine with Adaptive Card buttons for user decisions, persistent storage for workflow state, and proactive messaging for notifications. + +**When to use Power Automate instead:** Approval workflows that benefit from the built-in Approvals connector, and simple recurring tasks that business users should manage themselves. + +**Don't:** Build a hybrid system (some flows in Power Automate, some in the bot) unless you have a clear organizational reason. Two systems means two places to debug. + +--- + +### Y19. App Distribution — Best Practice + +**Start with sideloading for dev/test, use org catalog for internal deployment, and Partner Center only for public distribution.** + +Sideloading checklist: +1. `manifest.json` — schema v1.19+, valid `id`, correct `botId` +2. `color.png` — 192x192 full-color icon +3. `outline.png` — 32x32 transparent monochrome outline +4. ZIP all three (no nested folders) +5. Upload via Teams client → Apps → Manage your apps + +**Don't:** Submit to Partner Center (public store) until the bot is fully stable. The 1–2 week review cycle makes iteration slow. Use org catalog for internal users. + +--- + +## Summary: Gap Asymmetry + +Most RED gaps are asymmetric — they only apply in one direction. The pattern is clear: + +| Direction | RED gaps to handle | Why | +|---|---|---| +| **Slack → Teams** | 10 RED gaps | Teams lacks ephemeral, custom reactions, modal stacking, cancel notifications, mid-form updates, field validation, scheduling, channel archive, retroactive unfurl, Socket Mode | +| **Teams → Slack** | 0 RED gaps | Slack has native support for everything Teams offers, plus more | + +This means **adding Slack to a Teams bot is significantly easier** than adding Teams to a Slack bot. A Teams → Slack migration mostly involves mapping concepts 1:1 (Adaptive Cards → Block Kit, `app.on("message")` → `app.message()`, etc.) with few design decisions. A Slack → Teams migration requires redesigning multiple interaction patterns. + +### Effort Estimates by Bot Complexity + +| Profile | Slack Features Used | Slack → Teams Effort | Teams → Slack Effort | +|---|---|---|---| +| **A** — Simple | Messages, basic commands, simple cards | 8–16 hrs | 4–8 hrs | +| **B** — Moderate | A + ephemeral, threads, files, basic interactivity | 40–80 hrs | 8–16 hrs | +| **C** — Complex | B + shortcuts, App Home, unfurling, dynamic selects, modals | 80–160 hrs | 16–32 hrs | +| **D** — Full | C + workflows, scheduling, Socket Mode, stacked modals | 160–300 hrs | 32–48 hrs | diff --git a/plugins/microsoft-365-agents-toolkit/skills/teams-app-developer/docs/files-and-links.md b/plugins/microsoft-365-agents-toolkit/skills/teams-app-developer/docs/files-and-links.md new file mode 100644 index 0000000..4f9bc3a --- /dev/null +++ b/plugins/microsoft-365-agents-toolkit/skills/teams-app-developer/docs/files-and-links.md @@ -0,0 +1,85 @@ +# Files & Links + +## File Upload + +| Aspect | Slack | Teams | +|---|---|---| +| Upload API | `files.uploadV2()` — single call | FileConsentCard → user consent → Graph API upload (3-step flow) | +| Large files | Handled automatically | Graph resumable upload sessions for >4 MB | +| Sharing links | `files.sharedPublicURL()` | Graph `createLink()` | +| File events | `file_shared` event | Check `activity.attachments` in message handler | +| Download | `files.info()` → `url_private` with Bearer token | `attachment.content.downloadUrl` (pre-authenticated, short-lived) | +| Manifest config | None | `supportsFiles: true` required | +| Context | Works in channels and DMs | FileConsentCard works in personal chat only; channels use direct Graph upload | + +**Rating:** YELLOW (Slack → Teams), GREEN (Teams → Slack). + +### Impact + +Slack's one-call `files.uploadV2()` becomes a 3-step flow in Teams: send consent card → user approves → upload via Graph API. Missing the `supportsFiles: true` manifest flag causes silent failure. + +### Mitigation Strategies (Slack → Teams) + +| Strategy | How | Effort | +|---|---|---| +| **`sendFile()` helper (Recommended)** | Unified wrapper: auto-detects personal/channel context, routes to OneDrive or SharePoint, handles >4 MB chunking. The manual flow is error-prone. | 24–40 hrs | +| **Manual FileConsentCard** | Implement the 3-step consent flow directly. Works but verbose and easy to get wrong. | 16–24 hrs per upload pattern | + +### Reverse Direction (Teams → Slack) + +Use `files.uploadV2()` directly — much simpler than the Teams consent flow. No consent step needed. + +### File Download + +| Aspect | Slack | Teams | +|---|---|---| +| URL lifetime | Permanent (with valid token) | Pre-authenticated URL expires quickly | +| Auth required | Bearer token in request | URL is pre-authenticated | + +**Mitigation:** For Teams downloads, use the URL immediately or cache the file. Don't store the download URL for later use. + +--- + +## Link Unfurling / Previews + +| Aspect | Slack | Teams | +|---|---|---| +| Event | `link_shared` event (async) | `message.ext.query-link` handler (synchronous) | +| Response deadline | 30 minutes (via `chat.unfurl()`) | **5 seconds** | +| Domain matching | Wildcards supported (`*.example.com`) | **Exact domain only** — must enumerate every subdomain | +| Manifest config | Event subscription in app settings | `composeExtensions[].messageHandlers[].value.domains` | +| Retroactive unfurling | Unfurls links in existing messages | **New messages only** | +| Response format | Attachment with Block Kit | Adaptive Card via `composeExtension` result | + +**Rating:** YELLOW for basic unfurling, RED for retroactive unfurling and wildcard domains. + +### Impact + +The 5-second deadline is the critical difference. Slack allows async unfurling up to 30 minutes later. Teams requires a synchronous response within 5 seconds — any slow data source (API call, database query, rendering) will silently fail. + +### Mitigation Strategies (Slack → Teams) + +| Strategy | How | Effort | +|---|---|---| +| **Cache-first with prefetch (Recommended)** | Cache middleware wraps the handler. Pre-populate cache for known URLs. Without this, slow unfurls silently die. | 12–16 hrs | +| **Synchronous handler only** | Direct handler, must return within 5 seconds. Only viable for fast data sources (in-memory, pre-cached). | 4–8 hrs | + +### Wildcard Domains (Slack → Teams) + +| Strategy | How | Effort | +|---|---|---| +| **Manual enumeration (Recommended)** | List every subdomain in manifest `domains` array. Fine for <10 subdomains. | 1–2 hrs | +| **Manifest generator script** | Script reads subdomains from config and generates the manifest array. Worth it for 10+ subdomains. | 4–8 hrs | + +### Reverse Direction (Teams → Slack) + +Use `link_shared` event with `chat.unfurl()`. Slack supports wildcards and async responses — both are easier than the Teams model. + +### Retroactive Unfurling + +| Direction | Behavior | +|---|---| +| Slack → Teams | **Platform gap.** Teams only unfurls links in new messages. No workaround exists. Consider a bot command where users paste a URL to get a preview card. | +| Teams → Slack | Slack unfurls links retroactively by default. No issue. | + +**Rating:** RED — no mitigation. Accept the limitation. diff --git a/plugins/microsoft-365-agents-toolkit/skills/teams-app-developer/docs/identity-and-auth.md b/plugins/microsoft-365-agents-toolkit/skills/teams-app-developer/docs/identity-and-auth.md new file mode 100644 index 0000000..d8ab234 --- /dev/null +++ b/plugins/microsoft-365-agents-toolkit/skills/teams-app-developer/docs/identity-and-auth.md @@ -0,0 +1,107 @@ +# Identity & Auth + +## User IDs + +| Aspect | Slack | Teams | +|---|---|---| +| Format | Prefixed strings: `U...` (user), `C...` (channel), `T...` (team), `B...` (bot) | GUIDs: AAD object IDs, opaque conversation IDs | +| User identity | `message.user` → Slack user ID | `activity.from.id` → AAD object ID | +| Cross-reference | `users.info({ user })` → email, display name | `userGraph.me()` → email, display name | +| Channel identity | `channel_id` (flat namespace) | `conversation.id` (scoped to team) | + +**Rating:** RED — IDs are completely incompatible. No conversion formula exists. + +### Impact + +Any stored data keyed by Slack user/channel IDs (preferences, history, permissions) cannot be directly used with Teams IDs. A mapping layer is required. + +### Mitigation Strategy + +Use **email** as the common identity attribute: + +1. Build a mapping table: `Slack user ID → email → AAD Object ID` +2. Populate from Slack's `users.info()` and Teams' Graph API `users/{id}` +3. Re-key stored data during migration +4. For new dual-platform bots, key data by email from the start + +Effort: 8–16 hrs depending on data volume. + +--- + +## Authentication & Signing + +| Aspect | Slack | Teams | +|---|---|---| +| Request verification | `signingSecret` — HMAC-SHA256 of `v0:{timestamp}:{body}` | Bot Framework JWT — automatic validation by SDK | +| Manual verification | Required if using raw HTTP | Required only without SDK (REST-only integration) | +| Bot credentials | `SLACK_BOT_TOKEN` (xoxb-...) | `CLIENT_ID` + `CLIENT_SECRET` + `TENANT_ID` | +| App-level token | `SLACK_APP_TOKEN` (xapp-..., Socket Mode only) | Not applicable | + +**Rating:** GREEN — both SDKs handle verification automatically. + +**Mitigation:** No code changes needed when using SDKs. Both handle signing/verification internally. For REST-only integrations, see the verification patterns below. + +### REST-Only Verification + +**Slack (manual HMAC):** +``` +signature = HMAC-SHA256(signingSecret, "v0:{timestamp}:{rawBody}") +compare with X-Slack-Signature header +reject if timestamp > 5 minutes old +``` + +**Teams (manual JWT):** +``` +fetch OpenID config from https://login.botframework.com/v1/.well-known/openidconfiguration +validate JWT from Authorization header +verify audience = your bot's CLIENT_ID +verify issuer = https://api.botframework.com +``` + +--- + +## OAuth & Tokens + +| Aspect | Slack | Teams | +|---|---|---| +| User OAuth | `users:read`, `chat:write`, etc. scopes | Azure AD Graph permissions (delegated) | +| Bot token | `xoxb-...` (per-workspace) | Bot Framework token (per-tenant, auto-managed) | +| Token storage | `InstallationStore` (per-workspace bot+user tokens) | Not needed — SDK handles token lifecycle | +| SSO | Not native — redirect flow | Built-in with `oauth: { defaultConnectionName }` | +| Sign-in flow | OAuth redirect to Slack authorize URL | `ctx.signin()` sends OAuth card in chat | +| Sign-out | Revoke token via API | `ctx.signout()` | +| Multi-tenant | `InstallationStore` with per-workspace tokens | `signInAudience: "AzureADMultipleOrgs"` in Azure AD | + +**Rating:** YELLOW — both support OAuth but the flows and storage models differ significantly. + +### Key Difference + +Slack requires per-workspace token management via `InstallationStore`. Teams SDK manages tokens automatically — you just configure `clientId`, `clientSecret`, `tenantId`, and `oauth.defaultConnectionName`. + +### Mitigation (Slack → Teams) + +1. Remove `InstallationStore` (not needed) +2. Register an OAuth connection in Azure Bot resource settings +3. Add `oauth: { defaultConnectionName: "graph" }` to App constructor +4. Guard handlers with `ctx.isSignedIn` check +5. Call `ctx.signin()` when authentication is needed + +### Mitigation (Teams → Slack) + +1. Implement `InstallationStore` for per-workspace token storage +2. Configure OAuth scopes in Slack app settings +3. Set up `InstallProvider` for the OAuth install flow +4. Store bot and user tokens per workspace +5. Use stored tokens for API calls via `WebClient` + +### Slack OAuth Scopes → Teams Graph Permissions + +| Slack Scope | Teams Graph Permission | Notes | +|---|---|---| +| `users:read` | `User.Read` (delegated) | | +| `users:read.email` | `User.Read` (delegated) | Email included by default | +| `chat:write` | Bot sends via SDK (no permission) | | +| `channels:read` | `Channel.ReadBasic.All` | | +| `channels:history` | `ChannelMessage.Read.All` | | +| `files:read` | `Files.Read` | | +| `files:write` | `Files.ReadWrite` | | diff --git a/plugins/microsoft-365-agents-toolkit/skills/teams-app-developer/docs/infrastructure.md b/plugins/microsoft-365-agents-toolkit/skills/teams-app-developer/docs/infrastructure.md new file mode 100644 index 0000000..79853e7 --- /dev/null +++ b/plugins/microsoft-365-agents-toolkit/skills/teams-app-developer/docs/infrastructure.md @@ -0,0 +1,178 @@ +# Infrastructure + +## Transport + +| Aspect | Slack | Teams | +|---|---|---| +| Primary transport | Socket Mode (WebSocket) or HTTP | **HTTPS only** (inbound webhook) | +| Firewall-friendly | Socket Mode — outbound WebSocket, no inbound ports | **Requires public HTTPS endpoint** | +| Default endpoint | `/slack/events` | `/api/messages` | +| Local development | Socket Mode (no tunnel needed) | Dev Tunnels or ngrok required | +| Request verification | HMAC-SHA256 (`signingSecret`) | Bot Framework JWT (automatic) | + +**Rating:** GREEN for HTTP-to-HTTPS, RED for Socket Mode → HTTPS (firewall environments). + +### Impact + +Slack bots using Socket Mode run behind firewalls with zero inbound ports. Teams requires a public HTTPS endpoint — a fundamental architecture change for firewall-restricted environments. + +### Mitigation (Slack Socket Mode → Teams) + +| Strategy | How | Effort | +|---|---|---| +| **Deploy to Azure (Recommended)** | Host in Azure App Service / Functions / Container Apps. Use Dev Tunnels for local dev. | 4–8 hrs | +| **Azure Relay** | Hybrid connection for strict on-premises firewalls. Adds latency. | 8–16 hrs | + +### Dual-Bot Transport + +For bots targeting both platforms simultaneously: + +| Pattern | How | +|---|---| +| **Socket Mode + HTTP (Recommended)** | Slack uses WebSocket (no HTTP needed), Teams uses Express on port 3978. No port conflicts. Simplest setup. | +| **Shared Express** | Both use HTTP. Slack `ExpressReceiver` at `/slack/events`, Teams adapter at `/api/messages`. Requires careful body-parsing middleware ordering. | + +--- + +## Compute (AWS ↔ Azure) + +| AWS | Azure | Notes | +|---|---|---| +| Lambda + API Gateway | Azure Functions | Teams bots need 3-second response; Functions Consumption has 5–10s cold starts | +| ECS / Fargate | Container Apps | Best for long-running bots with streaming | +| EC2 | App Service | Always-on, predictable latency | + +### Cold Start Warning + +Azure Functions Consumption plan has 5–10 second cold starts that violate the Teams 3-second response timeout. Mitigations: + +| Strategy | Cost Impact | +|---|---| +| **App Service with Always On (Recommended)** | Fixed cost but no cold starts | +| **Functions Premium with Always Ready** | Higher cost, eliminates cold starts | +| **Container Apps (min replicas ≥ 1)** | Moderate cost, no scale-to-zero | + +--- + +## Storage (AWS ↔ Azure) + +| AWS | Azure | Notes | +|---|---|---| +| S3 | Blob Storage | Hot/Cool/Archive tiers | +| DynamoDB | Cosmos DB | Table API (lowest effort) or Core SQL (richer querying) | +| RDS (MySQL) | Azure Database for MySQL | Managed migration service available | +| RDS (PostgreSQL) | Azure Database for PostgreSQL | Managed migration service available | +| RDS (SQL Server) | Azure SQL | Direct migration path | + +### Bot State Storage + +| Aspect | Slack | Teams | +|---|---|---| +| SDK storage | No built-in state management | `IStorage` interface with pluggable backends | +| Default | Developer manages state | In-memory (lost on restart) | +| Production | External DB (Redis, PostgreSQL, etc.) | Cosmos DB, Azure SQL, or custom `IStorage` | + +**Mitigation:** Implement the Teams `IStorage` interface with Cosmos DB for bot state. Use serverless pricing for development, provisioned RUs for production. + +--- + +## Secrets & Configuration + +| AWS | Azure | Notes | +|---|---|---| +| Secrets Manager | Key Vault | Sensitive credentials | +| SSM Parameter Store | App Configuration | Non-secret configuration | +| IAM roles | Managed Identity | Zero-secret authentication | +| Environment variables | App Settings | Runtime configuration | + +### Bot Credentials + +| Credential | Slack | Teams | +|---|---|---| +| Bot token | `SLACK_BOT_TOKEN` | Managed by SDK (`CLIENT_ID` + `CLIENT_SECRET`) | +| Signing/verification | `SLACK_SIGNING_SECRET` | Automatic JWT validation | +| Socket Mode | `SLACK_APP_TOKEN` | N/A | +| Tenant | N/A | `TENANT_ID` | + +### Production Secret Management + +| Strategy | How | +|---|---| +| **Key Vault references (Recommended)** | `@Microsoft.KeyVault(SecretUri=...)` in App Settings. Zero-code secret injection. Requires managed identity. | +| **Managed identity for bot auth** | `managedIdentityClientId: "system"` in App constructor. Eliminates `CLIENT_SECRET` entirely. | +| **`DefaultAzureCredential`** | Chains managed identity → environment → CLI → VS Code. Works everywhere. | + +--- + +## Observability (AWS ↔ Azure) + +| AWS | Azure | Notes | +|---|---|---| +| CloudWatch Logs | Application Insights + Log Analytics | KQL query language (different from CloudWatch Insights) | +| CloudWatch Metrics | Azure Monitor Metrics | `trackMetric()` | +| CloudWatch Alarms | Azure Monitor Alerts | KQL-based alerting | +| X-Ray | Application Insights distributed tracing | Operation IDs, `traceparent` headers | + +### Bot Health Monitoring + +Key metrics to track for both platforms: + +| Metric | Why | +|---|---| +| Request rate | Volume baseline | +| Response time (P50/P95/P99) | Detect slowdowns before they cause timeouts | +| Failure rate | Catch errors before users report them | +| Active conversations | Usage trends | +| AI/external API latency | Dependency health | + +### Setup + +```typescript +// Application Insights — must be first import +import appInsights from "applicationinsights"; +appInsights.setup(process.env.APPLICATIONINSIGHTS_CONNECTION_STRING).start(); + +// Then import everything else +import { App } from "@microsoft/teams.apps"; +``` + +**Pitfall:** Late instrumentation import. `applicationinsights` must run before `http`/`https` are loaded or distributed tracing won't work. + +--- + +## Rate Limiting & Resilience + +| Aspect | Slack | Teams | +|---|---|---| +| Rate limit signal | HTTP 429 + `Retry-After` header | HTTP 429 + `Retry-After` header | +| Built-in retry | Bolt `retryConfig` option | **No built-in retry** | +| Conversation limits | ~1 msg/sec per method per token | ~1 msg/sec per conversation, ~30 msg/min per conversation | +| Graph API limits | N/A | Separate throttling (per-app per-tenant) | +| Invoke timeout | N/A | 3–10 seconds (varies by invoke type) | + +**Rating:** GREEN for basic rate limiting, YELLOW for resilience patterns. + +### Mitigation (Slack → Teams) + +Build a `RetryPlugin` with exponential backoff + jitter: + +| Component | Purpose | +|---|---| +| **Exponential backoff** | Wait 1s, 2s, 4s, 8s between retries | +| **Jitter** | Add random delay to prevent thundering herd | +| **Circuit breaker** | Stop retrying after N consecutive failures | +| **`p-queue`** | Concurrency control for proactive broadcast (avoid bursting) | + +Effort: 12–16 hrs for a production-grade retry plugin. + +### Reverse Direction (Teams → Slack) + +Use Bolt's built-in `retryConfig` option: + +```typescript +const app = new App({ + token: process.env.SLACK_BOT_TOKEN!, + signingSecret: process.env.SLACK_SIGNING_SECRET!, + // Built-in retry with exponential backoff +}); +``` diff --git a/plugins/microsoft-365-agents-toolkit/skills/teams-app-developer/docs/interactive-responses.md b/plugins/microsoft-365-agents-toolkit/skills/teams-app-developer/docs/interactive-responses.md new file mode 100644 index 0000000..16d81fe --- /dev/null +++ b/plugins/microsoft-365-agents-toolkit/skills/teams-app-developer/docs/interactive-responses.md @@ -0,0 +1,98 @@ +# Interactive Responses + +## Ephemeral Messages + +| Aspect | Slack | Teams | +|---|---|---| +| User-only messages | `chat.postEphemeral()` or `respond({ response_type: "ephemeral" })` | **No native equivalent** | +| Per-user card views | Not available (use ephemeral messages) | `Action.Execute` with `refresh.userIds` | +| Default command response | Ephemeral | Visible to everyone | + +**Rating:** RED (Slack → Teams), GREEN (Teams → Slack). + +### Impact + +Any Slack bot that uses ephemeral messages for user-only feedback — confirmation dialogs, error messages, inline help — has no direct Teams equivalent. Messages are visible to everyone unless workarounds are used. + +### Mitigation Strategies (Slack → Teams) + +| Strategy | How | Effort | +|---|---|---| +| **`refresh.userIds` (Recommended)** | Adaptive Cards with `Action.Execute` and `refresh.userIds` show different card content per user. Covers ~80% of cases. Limited to 60 user IDs. | 4–8 hrs | +| **1:1 chat fallback** | Route ephemeral content to user's personal bot chat via proactive messaging. Different UX (separate conversation) but reliable. | 2–4 hrs | +| **`sendEphemeral()` helper** | Wrapper that auto-detects context and picks the best strategy. Worth it if reused across multiple handlers. | 8–12 hrs | +| **Drop ephemeral behavior** | Show messages to everyone. Simplest but may expose private data. | 0 hrs | + +### Reverse Direction (Teams → Slack) + +`refresh.userIds` per-user card views map to Slack's native ephemeral messages. Use `chat.postEphemeral()` directly. + +--- + +## Message Updates and Replacements + +| Aspect | Slack | Teams | +|---|---|---| +| Replace original | `respond({ replace_original: true })` | Return card from invoke handler, or `ctx.updateActivity(activityId)` | +| Delete original | `respond({ delete_original: true })` | `ctx.deleteActivity(activityId)` | +| Update by ID | `chat.update({ ts, channel, ... })` | `ctx.updateActivity({ id: activityId, ... })` | +| Response URL | `response_url` — valid 30 min, max 5 uses | No equivalent concept | +| Activity ID | `message.ts` (timestamp-based) | `activity.id` or `activity.replyToId` | + +**Rating:** GREEN — direct mapping with different API shapes. + +### Key Difference + +Slack uses `response_url` (a webhook URL that expires after 30 minutes and allows up to 5 responses). Teams has no `response_url` — you update messages by storing and referencing the `activity.id`. + +**Mitigation:** Store the `activity.id` when sending messages that may need updating. Use `ctx.updateActivity()` with the stored ID. + +--- + +## Button Actions + +| Aspect | Slack | Teams | +|---|---|---| +| Handler registration | `app.action("action_id", handler)` | `app.on("card.action", handler)` routing on `data.action` | +| Action identifier | `action_id` on button element | `data.action` (or `data.verb`) in `Action.Submit` | +| Button value | `action.value` | `activity.value.action.data` | +| Acknowledgement | Must `ack()` within 3 seconds | Automatic | +| Follow-up response | `respond()` (response_url) | `ctx.send()` or `ctx.updateActivity()` | + +**Rating:** GREEN — direct mapping with different routing mechanisms. + +**Mitigation:** In Slack, each button has a unique `action_id` with its own handler. In Teams, all `Action.Submit` buttons route through `card.action`; use a `data.action` field to dispatch: + +```typescript +// Teams — route by data.action +app.on("card.action", async (ctx) => { + const action = ctx.activity.value?.action?.data?.action; + switch (action) { + case "approve": /* ... */ break; + case "reject": /* ... */ break; + } +}); +``` + +--- + +## Confirmation Dialogs + +| Aspect | Slack | Teams | +|---|---|---| +| Native support | `confirm` object on button elements | **No native equivalent** | +| Behavior | Platform-rendered "Are you sure?" popup | Must be built manually | + +**Rating:** YELLOW (Slack → Teams), GREEN (Teams → Slack). + +### Mitigation Strategies (Slack → Teams) + +| Strategy | How | Effort | +|---|---|---| +| **`Action.ShowCard` inline (Recommended)** | Inline expand with "Are you sure?" text and Yes/No buttons. Native Adaptive Card pattern. | 2–4 hrs | +| **Task module confirm** | Small dialog popup for confirmation. More prominent, closer to Slack UX. | 4–6 hrs | +| **`confirmAction()` helper** | Template function generating confirm cards. Reusable across multiple buttons. | 4–8 hrs | + +### Reverse Direction (Teams → Slack) + +Use the native `confirm` object on button elements. Built-in, no custom code needed. diff --git a/plugins/microsoft-365-agents-toolkit/skills/teams-app-developer/docs/messaging-and-commands.md b/plugins/microsoft-365-agents-toolkit/skills/teams-app-developer/docs/messaging-and-commands.md new file mode 100644 index 0000000..72a5596 --- /dev/null +++ b/plugins/microsoft-365-agents-toolkit/skills/teams-app-developer/docs/messaging-and-commands.md @@ -0,0 +1,102 @@ +# Messaging & Commands + +## Message Handling + +| Aspect | Slack | Teams | +|---|---|---| +| Handler | `app.message(pattern, handler)` | `app.on("message", handler)` | +| Pattern matching | String (substring), RegExp, or catch-all | RegExp or manual `text.match()` | +| Reply to channel | `say(text)` | `ctx.send(text)` | +| Reply in thread | `say({ text, thread_ts })` | `ctx.reply(text)` | +| Get message text | `message.text` | `ctx.activity.text` | +| Get sender | `message.user` (Slack ID) | `ctx.activity.from.id` (AAD ID) | + +**Rating:** GREEN — direct mapping in both directions. + +**Mitigation:** Extract message handling into a platform-agnostic service layer that receives `(text, userId, platform)` and returns structured data. Each adapter converts to the platform's native format. + +--- + +## Slash Commands + +| Aspect | Slack | Teams | +|---|---|---| +| Invocation | `/command args` | No native equivalent | +| Handler | `app.command("/cmd", handler)` | `app.on("message")` with text pattern matching | +| Acknowledgement | Must `ack()` within 3 seconds | Automatic — no `ack()` | +| Default response | Ephemeral (user-only) | Visible to everyone | +| Modal trigger | `trigger_id` from command → `views.open()` | `dialog.open` handler or Adaptive Card form | +| Registration | Slack app dashboard + `commands` scope | Manifest `commands[]` array (bot commands, not slash) | + +**Rating:** YELLOW — functional equivalent exists but UX is fundamentally different. + +### Impact + +- Slash commands are a core Slack interaction pattern with no Teams counterpart +- Teams bot commands appear in a command menu but don't use `/` prefix +- Ephemeral responses don't exist in Teams + +### Mitigation Strategies + +| Strategy | How | Effort | +|---|---|---| +| **Text commands (Recommended)** | Detect command-like patterns in `app.on("message")`. Accept both `weather` and `/weather`. | 2–4 hrs | +| **Manifest bot commands** | Add `commands[]` to manifest for discoverability in Teams command menu. Users type the command name. | 1–2 hrs | +| **Message extension** | Use `composeExtensions` for a richer command experience with search results or task modules. | 8–12 hrs | + +### Reverse Direction (Teams → Slack) + +Teams bot commands map directly to Slack slash commands via `app.command()`. Add `ack()` calls (required in Slack, absent in Teams) and configure the command in the Slack app dashboard. + +--- + +## Events / Activities + +| Slack Event | Teams Activity | Notes | +|---|---|---| +| `message` | `message` | Direct mapping | +| `app_mention` | `message` (in channel) | Teams channels require @mention by default | +| `member_joined_channel` | `conversationUpdate` (`membersAdded`) | Different event shape | +| `member_left_channel` | `conversationUpdate` (`membersRemoved`) | Different event shape | +| `reaction_added` | `messageReaction` | Teams has only 6 fixed reactions | +| `app_home_opened` | `install.add` (closest) | No "opened" event in Teams | +| `channel_created` | No equivalent | Use Graph API subscription | +| `team_join` | `conversationUpdate` (`membersAdded`) | Same event, different context | + +**Rating:** GREEN for most events, RED for custom emoji reactions. + +### @Mention Behavior + +| Aspect | Slack | Teams | +|---|---|---| +| Channel messages | Bot receives all messages in joined channels | Bot only receives messages with @mention (default) | +| Override | Default behavior | Add `ChannelMessage.Read.Group` RSC permission to manifest | +| Mention stripping | Not needed | Set `activity.mentions.stripText: true` in App options | + +**Mitigation:** To receive all channel messages in Teams without @mention, add RSC permission to the manifest. This is a config-only change (1–2 hrs). + +--- + +## Threading + +| Aspect | Slack | Teams | +|---|---|---| +| Reply in thread | `say({ thread_ts: message.ts })` | `ctx.reply(text)` | +| Thread broadcast | `say({ thread_ts, reply_broadcast: true })` | Two API calls: `reply()` + `send()` | +| Get thread replies | `conversations.replies({ ts })` | Graph API `GET /messages/{id}/replies` | +| Thread discovery | Native API | Requires `ChannelMessage.Read.All` Graph permission | + +**Rating:** GREEN for basic threading, YELLOW for broadcast and discovery. + +### Mitigation for Thread Broadcast + +Slack's `reply_broadcast` posts in both the thread and the channel in one call. Teams requires two separate calls: `reply()` for the thread and `send()` for the channel. Wrap in a helper: + +```typescript +async function replyWithBroadcast(ctx, text: string): Promise { + await ctx.reply(text); // Thread reply + await ctx.send(text); // Channel message +} +``` + +Effort: 1–2 hrs. diff --git a/plugins/microsoft-365-agents-toolkit/skills/teams-app-developer/docs/middleware-and-handlers.md b/plugins/microsoft-365-agents-toolkit/skills/teams-app-developer/docs/middleware-and-handlers.md new file mode 100644 index 0000000..c0f73f2 --- /dev/null +++ b/plugins/microsoft-365-agents-toolkit/skills/teams-app-developer/docs/middleware-and-handlers.md @@ -0,0 +1,97 @@ +# Middleware & Handler Patterns + +## Middleware + +| Aspect | Slack (Bolt) | Teams SDK v2 | +|---|---|---| +| Global middleware | `app.use(async ({ next }) => { ... await next(); })` | `app.use(async (ctx) => { ... ctx.next(); })` | +| Chaining | Explicit `await next()` — omitting drops the event silently | Explicit `ctx.next()` — omitting stops the pipeline | +| Listener middleware | Passed as extra args to `app.message(filter, middleware, handler)` | No equivalent — use guard functions at handler start | +| Authorization | Custom middleware checking Slack user/workspace | Bot Framework JWT validation is automatic | + +**Rating:** GREEN — both have middleware, but Slack's is more granular. + +### Key Difference + +Slack supports **listener middleware** — functions that run only for specific handlers. Teams has no equivalent. Convert listener middleware to guard conditions at the top of each handler: + +```typescript +// Slack: listener middleware +app.message(isAdmin, async ({ say }) => { await say("Admin action"); }); + +// Teams: guard function +app.on("message", async (ctx) => { + if (!isAdmin(ctx.activity.from.id)) return; + await ctx.send("Admin action"); +}); +``` + +--- + +## Acknowledgement (`ack()`) + +| Aspect | Slack | Teams | +|---|---|---| +| Required for | Commands, actions, view submissions, shortcuts, options | **Not applicable** — SDK handles automatically | +| Deadline | 3 seconds | No manual acknowledgement | +| What happens if missed | Slack shows "This app didn't respond" error to user | N/A | +| Payload in ack | Commands: optional text/blocks (ephemeral). View submissions: optional `response_action`. | N/A | + +**Rating:** GREEN — remove `ack()` calls when porting Slack → Teams. + +### Impact + +`ack()` is fundamental to Slack's interaction model. Every interactive handler must acknowledge within 3 seconds or the user sees an error. Teams has no equivalent — the SDK handles response timing automatically. + +### Mitigation + +| Direction | Strategy | +|---|---| +| Slack → Teams | Remove all `ack()` calls. Move async work that previously happened "after ack" into the main handler body. | +| Teams → Slack | Add `await ack()` as the first line of every command, action, view, shortcut, and options handler. Do async work after. | + +--- + +## Handler Registration + +| Aspect | Slack (Bolt) | Teams SDK v2 | +|---|---|---| +| Messages | `app.message(pattern, handler)` | `app.message(pattern, handler)` or `app.on("message", handler)` | +| Events | `app.event("event_name", handler)` | `app.on("routeName", handler)` | +| Actions | `app.action("action_id", handler)` | `app.on("card.action", handler)` — route by `data.action` | +| Modals | `app.view("callback_id", handler)` | `app.on("dialog.submit", handler)` | +| Shortcuts | `app.shortcut("callback_id", handler)` | `app.on("message.ext.open", handler)` | +| Options/typeahead | `app.options("action_id", handler)` | `Input.ChoiceSet` with `style: "filtered"` (client-side) | +| Install events | No built-in handler | `app.on("install.add", handler)` | +| Lifecycle events | No built-in handler | `app.event("start" | "error" | "signin" | "activity")` | +| Order matters | First `app.message()` match wins | First match wins for `app.message()`, last registration wins for `app.on()` | + +**Rating:** GREEN — different APIs, same concepts. + +### Key Mapping + +``` +Slack Teams +───────────────────────────── ───────────────────────────── +app.message(pattern) → app.message(pattern) +app.command("/cmd") → app.on("message") + text match +app.action("id") → app.on("card.action") +app.view("callback_id") → app.on("dialog.submit") +app.event("name") → app.on("routeName") +app.shortcut("id") → app.on("message.ext.open") +app.options("id") → (client-side filtered ChoiceSet) +app.use(middleware) → app.use(middleware) +app.error(handler) → app.event("error", handler) +``` + +--- + +## Error Handling + +| Aspect | Slack (Bolt) | Teams SDK v2 | +|---|---|---| +| Global handler | `app.error(async (error) => { ... })` | `app.event("error", ({ error, log }) => { ... })` | +| Unhandled errors | Logged to stderr, process continues | Logged, process continues | +| Per-handler errors | try/catch in individual handlers | try/catch in individual handlers | + +**Rating:** GREEN — equivalent patterns. diff --git a/plugins/microsoft-365-agents-toolkit/skills/teams-app-developer/docs/ui-components.md b/plugins/microsoft-365-agents-toolkit/skills/teams-app-developer/docs/ui-components.md new file mode 100644 index 0000000..caeb4a9 --- /dev/null +++ b/plugins/microsoft-365-agents-toolkit/skills/teams-app-developer/docs/ui-components.md @@ -0,0 +1,137 @@ +# UI Components + +## Block Kit vs Adaptive Cards + +| Slack Block Kit | Adaptive Card Element | Notes | +|---|---|---| +| `section` (text) | `TextBlock` | Set `wrap: true`; convert `*bold*` mrkdwn → `**bold**` Markdown | +| `section` (fields) | `FactSet` | Each field becomes `{ title, value }` | +| `section` (text + accessory) | `ColumnSet` with 2 `Column`s | Col 1 = text, Col 2 = accessory | +| `header` | `TextBlock` size `Large`, weight `Bolder` | | +| `actions` | `ActionSet` | Max 6 actions in Teams (vs 25 in Slack) | +| `divider` | `TextBlock` with `separator: true` | | +| `image` | `Image` | `alt_text` (underscore) → `altText` (camelCase) | +| `context` | `TextBlock` size `Small`, `isSubtle: true` | | +| `input` (plain_text) | `Input.Text` | | +| `input` (static_select) | `Input.ChoiceSet` `style: "compact"` | | +| `input` (multi_select) | `Input.ChoiceSet` `isMultiSelect: true` | | +| `input` (datepicker) | `Input.Date` | | +| `input` (timepicker) | `Input.Time` | | +| `input` (checkboxes) | `Input.ChoiceSet` `style: "expanded"`, `isMultiSelect: true` | | +| `input` (radio_buttons) | `Input.ChoiceSet` `style: "expanded"` | | +| `rich_text` | `RichTextBlock` | Schema 1.5+ | +| `overflow` menu | **No equivalent** | Redesign as `ActionSet` or `Input.ChoiceSet` dropdown | + +**Rating:** GREEN for most elements, RED for overflow menus. + +### Markdown Differences + +| Formatting | Slack mrkdwn | Adaptive Card Markdown | +|---|---|---| +| Bold | `*bold*` | `**bold**` | +| Italic | `_italic_` | `_italic_` | +| Strikethrough | `~strike~` | `~~strike~~` | +| Code | `` `code` `` | `` `code` `` | +| Emoji | `:emoji_shortcode:` | Unicode characters only | +| User mention | `<@U12345>` | Display name (no mention syntax) | + +**Impact:** Failing to convert `*bold*` to `**bold**` produces literal asterisks in Teams. Slack emoji shortcodes render as plain text in Adaptive Cards. + +**Mitigation:** Apply a text transform function when converting between formats: + +```typescript +// Slack mrkdwn → Adaptive Card Markdown +text.replace(/(? 0, there's a conflict. The bot renders the conflicting bookings and suggests the next available slot. + +--- + +## Scenario 4: Account Health Monitoring (CRM) + +**Audience:** Sales teams, account managers, customer success. + +### User Flow + +1. Weekly scheduled prompt posts to the sales channel: "Time for account health check-ins" +2. Each account owner fills in: Account name, health status (Green/Yellow/Red), notes, next meeting date +3. Responses aggregate into a durable account health list +4. Stale accounts flagged: if no update in 30 days, bot sends a dormant account alert +5. Before a meeting, manager types "summarize Acme Corp" — AI pulls the last 4 check-ins and renders a trend card + +### Five Elements + +| Element | Implementation | +|---|---| +| Trigger | Weekly cron schedule. Dormant-account check (daily timer queries for last-update > 30 days) | +| State | SharePoint List: AccountName, Owner, HealthStatus (Green/Yellow/Red), Notes, NextMeeting, LastUpdated | +| Logic | Staleness detection: daily timer queries `fields/LastUpdated lt '{30-days-ago}'`. Proactive alert to owner | +| Intelligence | `queryAccountHealth(account?, status?, owner?)` — "Show all red accounts", "Summarize Acme Corp history" | +| Visibility | Check-in prompt card. Account status card (color-coded). Dormant account alert. Trend summary card | + +### Trend Analysis + +The AI function returns the last N check-ins for an account. The LLM summarizes: + +> *"Acme Corp: 4 check-ins over the last month. Trend: Yellow → Yellow → Red → Red. Key issue: delayed contract renewal (first flagged March 1). Next meeting: March 15."* + +This is the "intelligence layered over structured state" pattern — the primary differentiation opportunity called out in the source document. + +--- + +## Scenario 5: Frontline Break Management + +**Audience:** Frontline workers, call centers (e.g., T-Mobile scenario from source document). + +### User Flow + +1. Agent changes presence to "Away" (auto-detected via Graph presence subscription) +2. Bot removes agent from call queue and starts break timer +3. At 15 minutes, bot sends a reminder card to the agent and their manager +4. At 20 minutes, bot escalates — posts an alert card in the manager channel +5. Agent changes presence to "Available" — bot re-adds to queue, records break duration +6. Manager types "who is on break?" or "average break duration today" — AI queries and responds + +### Five Elements + +| Element | Implementation | +|---|---| +| Trigger | Graph change notification subscription on `/communications/presences/{userId}` | +| State | SharePoint List: EmployeeName, BreakStart, BreakEnd, DurationMinutes, Status (Active/Ended/Escalated) | +| Logic | Timer-based escalation (15 min reminder, 20 min escalate). Call queue add/remove via Teams admin APIs. Break record created on "Away", updated on "Available" | +| Intelligence | `queryBreakStatus(currentOnly?)` — "Who is on break right now?", "Average break duration this week" | +| Visibility | Break started card (in manager channel). Reminder card (to agent). Escalation alert card. Break summary card | + +### Why This Is Teams-Native + +This scenario depends on three capabilities Slack cannot replicate: + +| Capability | Teams | Slack | +|---|---|---| +| Presence change subscriptions | Graph `/communications/presences` | Not available | +| Shift schedule integration | Shifts API | Not available | +| Call queue management | Teams admin APIs + Graph | Not available | + +### Technical Requirements + +- **Graph subscription for presence** requires `Presence.Read.All` application permission and encrypted rich notifications (public/private key pair for notification decryption) +- **Presence subscriptions expire in 60 minutes** — aggressive renewal required (55-minute interval) +- **Webhook must respond in 3 seconds** — process notifications asynchronously +- **In-memory timers don't survive restarts** — use Azure Durable Functions or a Redis-backed job queue for production + +--- + +## Scenario 6: Incident Response + +**Audience:** IT operations, DevOps, on-call teams. + +### User Flow + +1. On-call engineer types `/incident P1 Production database connection pool exhausted` +2. Bot creates an incident record, posts a structured incident card, and creates a dedicated incident thread +3. Bot proactively notifies the on-call rotation (looked up from a Shifts schedule or list) +4. Team members post updates in the thread — bot captures tagged updates (`/update Database restarted, monitoring`) +5. Engineer types `/resolve` — bot closes the incident, calculates MTTR, and posts a resolution summary +6. Post-incident: manager types "show P1 incidents this month" — AI generates a summary with MTTR trends + +### Five Elements + +| Element | Implementation | +|---|---| +| Trigger | `/incident PRIORITY DESCRIPTION` bot command | +| State | SharePoint List: IncidentId, Priority (P1-P4), Description, Status (Open/Investigating/Resolved), AssignedTo, CreatedAt, ResolvedAt, MTTR, Updates[] | +| Logic | Auto-assign from on-call rotation. Status transitions: Open → Investigating → Resolved. MTTR calculation on resolve. Thread-based update capture | +| Intelligence | `queryIncidents(priority?, status?, dateRange?)` — "Show open incidents", "MTTR trend for P1s this quarter" | +| Visibility | Incident card (color-coded by priority). Update timeline in thread. Resolution summary card with MTTR | + +--- + +## Composable Platform Pattern + +All six scenarios follow the same lifecycle. The composable platform approach (see `bridge/workflow.composable-platform-ts.md`) defines workflows as configuration: + +```typescript +interface WorkflowDefinition { + id: string; // "pto", "standup", "equipment" + commandPrefix: string; // "/pto", "/standup", "/book" + columns: ColumnDefinition[]; // SharePoint List schema + statusField: string; // Which column tracks lifecycle + routing?: RoutingConfig; // Approval chain config + cards: CardTemplates; // Active, completed, list, form + queryDescription: string; // AI function calling description + filterableColumns: string[]; // Columns exposed to NL queries +} +``` + +A single workflow engine registers handlers from definitions. New workflows require a new `WorkflowDefinition` object, not new handler code. Template workflows (standup, PTO, equipment) serve as reference implementations. + +### Scenario Comparison + +| Scenario | Trigger Types | Approval | State-Driven | NL Queries | Competitive Edge | +|---|---|---|---|---|---| +| Daily Standup | Scheduled, command | No | No | Blockers, summaries | Structured check-ins as durable records | +| PTO Requests | Command, extension | Yes (single/chain) | No | Status, date range, person | Approval routing + NL retrieval | +| Equipment Booking | Command, search | No | No | Availability, overdue | Conflict detection + alternatives | +| Account Health | Scheduled | No | Timer (staleness) | Trends, status, owner | Trend analysis over time | +| Break Management | Presence change | No | Yes (presence) | Current status, averages | Teams-only: presence + Shifts + call queues | +| Incident Response | Command | No | No | Priority, MTTR, status | Thread-based update capture + MTTR | + +--- + +## Platform Comparison: Teams vs Slack + +| Capability | Slack | Teams | Gap | +|---|---|---|---| +| In-channel workflow creation | Workflow Builder GUI | Power Automate (external) | Teams gap: no in-channel builder | +| Structured input forms | `OpenForm` built-in function | Adaptive Card forms (bot) or task modules | Parity | +| State persistence | Datastores (50K limit, Slack-hosted) | SharePoint Lists (30M limit, tenant-owned) | Teams advantage | +| Card interactivity | Block Kit (new message on action) | Action.Execute (in-place refresh) | Teams advantage | +| NL querying over state | Not built-in | AI function calling + structured data | Teams advantage | +| Presence/Shifts triggers | Not available | Graph subscriptions | Teams advantage | +| Call queue integration | Not available | Teams admin APIs | Teams advantage | +| No-code authoring | Workflow Builder | Power Automate | Slack advantage (simpler UX) | +| Hosting model | Slack-hosted (Deno) | Self-hosted or Azure | Trade-off | + +The core thesis: if Teams unifies its existing primitives at the message layer (which a bot can do today), it moves beyond parity — especially for operational and frontline workflows where Slack lacks system-level integration. diff --git a/plugins/microsoft-365-agents-toolkit/skills/teams-app-developer/experts/README.md b/plugins/microsoft-365-agents-toolkit/skills/teams-app-developer/experts/README.md new file mode 100644 index 0000000..10a53b6 --- /dev/null +++ b/plugins/microsoft-365-agents-toolkit/skills/teams-app-developer/experts/README.md @@ -0,0 +1,133 @@ +# Bot Platform Expert System + +A curated knowledge base for building conversational bots and AI agents across Slack and Microsoft Teams. These micro-experts guide AI coding assistants (Claude, Copilot, etc.) to produce correct, idiomatic code by loading only the relevant expertise for each task. + +## Goals + +1. **Accelerate bot development** by giving AI assistants deep, verified knowledge of both Slack and Teams SDKs — eliminating hallucinated APIs and outdated patterns. +2. **Support cross-platform scenarios** where a single team needs to ship bots on both Slack and Teams from the same codebase. +3. **Cover the full stack** from SDK initialization and webhook plumbing through AI integration, media handling, and infrastructure migration — not just "hello world" examples. +4. **Stay language-pragmatic** by focusing on TypeScript (the only language with first-class SDK support on both platforms) while providing REST-level guidance for Java, C#, Go, and other languages. + +## SDK Language Matrix + +| Language | Slack Bolt | Teams SDK | Recommendation | +|--------------------|-----------|-----------|---------------------------------------------------------------| +| TypeScript / JS | Full | Full | Best choice for dual-platform — both SDKs are first-class | +| Python | Full | Preview | Good for AI/ML workloads; Teams SDK still maturing | +| Java / JVM | Full | None | Use REST-only patterns for Teams (see `rest-only-integration`) | +| C# / .NET | None | Full | Use REST-only patterns for Slack (see `rest-only-integration`) | +| Go, Ruby, etc. | None | None | REST-only for both platforms | + +## Scenarios + +### 1. Build a Teams bot (TypeScript) + +Load the **Teams** domain. 28 micro-experts cover app initialization, routing, Adaptive Cards, dialogs, message extensions, OAuth/SSO, Graph API, AI (ChatPrompt, function calling, RAG, streaming, memory), MCP, A2A, and more. + +**Key experts:** `teams/runtime.app-init-ts.md`, `teams/runtime.routing-handlers-ts.md`, `teams/ui.adaptive-cards-ts.md` + +### 2. Build a Slack bot (TypeScript) + +Load the **Slack** domain. 7 micro-experts cover Bolt.js app setup, handler registration, ack rules, slash commands, Block Kit UI, Events API, Assistant containers, and OAuth/multi-workspace distribution. + +**Key experts:** `slack/runtime.bolt-foundations-ts.md`, `slack/bolt-events-ts.md`, `slack/bolt-assistant-ts.md` + +### 3. Host both bots in a single server + +Load the **Bridge** domain's architecture cluster. Covers shared Express server with route separation, Socket Mode + HTTP dual receiver, platform-agnostic service layer, and identity normalization. + +**Key expert:** `bridge/cross-platform-architecture-ts.md` + +### 4. Integrate from Java, C#, or Go (no native SDK) + +Load the **Bridge** domain's REST-only cluster. Language-agnostic pseudocode for Bot Framework REST API (Teams) and Slack Events API + Web API — manual JWT validation, HMAC signature verification, token acquisition, and message sending. + +**Key expert:** `bridge/rest-only-integration-ts.md` + +### 5. Bridge features between Slack and Teams + +Load the **Bridge** domain. 25 micro-experts cover bidirectional mapping of every feature: Block Kit ↔ Adaptive Cards, commands, events ↔ activities, identity, modals ↔ dialogs, files, shortcuts ↔ extensions, workflows ↔ Power Automate, infrastructure (Lambda ↔ Functions, S3 ↔ Blob), and more. + +**Key expert:** `bridge/cross-platform-advisor-ts.md` (orchestrates the full bridging workflow) + +### 6. Deploy your bot to Azure or AWS + +Load the **Deploy** domain. The router interviews you on cloud provider preference (Azure or AWS) and bot platform (Teams, Slack, or both), then loads the matching expert for a step-by-step walkthrough from CLI installation through verified deployment. + +**Key experts:** `deploy/azure-bot-deploy-ts.md`, `deploy/aws-bot-deploy-ts.md` + +### 7. Convert code from another language to TypeScript + +Load the **Convert** domain. 8 micro-experts cover JS→TS, Ruby→TS, Java→TS, Kotlin→TS, type mapping, dependency mapping, JSON serialization, and bulk conversion strategy. + +**Key experts:** `convert/java-to-ts-ts.md`, `convert/kotlin-to-ts-ts.md`, `convert/type-mapping-ts.md` + +## Expert Inventory + +### Root (6 files) +| File | Purpose | +|------|---------| +| `index.md` | Root task router — interviews developer, routes to domain | +| `fallback.md` | Recovery when no domain matches | +| `_expert-ts.md` | Template for creating new experts | +| `researcher.md` | Deep research workflow for fleshing out experts | +| `analyzer.md` | Analyze project and recommend new experts | +| `builder.md` | Build new experts from analysis recommendations | + +### Slack Domain (18 files) +Covers: Bolt.js foundations, ack rules, slash commands, shortcuts, Socket Mode, Block Kit, modals lifecycle, events API, assistant containers, OAuth/distribution, Web API/proactive messaging, Slack CLI (getting started, app management, manifest/triggers, datastore/env, local dev/deploy), Bolt for Python, Bolt for Java. + +### Teams Domain (35 files) +Covers: app init, routing, manifest, proactive messaging, Adaptive Cards, dialogs, message extensions, OAuth/SSO, Graph API, state/storage, AI (ChatPrompt, model setup, function calling, RAG, streaming, citations, memory), MCP (server, client, security, expose tools), A2A (server, client, orchestrator), BotBuilder interop, debug/test, scaffolding, Agents Toolkit (playground, environments, lifecycle CLI, publish), Teams for Python, Teams for .NET. + +### Bridge Domain (26 files) +Covers: Block Kit ↔ Adaptive Cards, commands, events ↔ activities, identity/OAuth bridge, middleware ↔ handlers, modals ↔ dialogs, App Home ↔ personal tab, legacy attachments, transport, infrastructure (compute, storage, secrets, observability), interactive responses, files, link unfurl ↔ preview, shortcuts ↔ extensions, scheduling, channel ops, workflows ↔ automation, distribution/packaging, rate limiting, cross-platform advisor, cross-platform architecture, REST-only integration, Python cross-platform. + +### Convert Domain (8 files) +Covers: JS→TS, Ruby→TS, Java→TS, Kotlin→TS, type mapping, dependency mapping, JSON serialization, bulk conversion strategy. + +### Models Domain (7 files) +Covers: OpenAI/Azure OpenAI, Anthropic, AWS Bedrock, Azure AI Foundry (cloud), Foundry Local, OSS/OpenAI-compatible, Transformers.js. + +### Deploy Domain (4 files) +Covers: Azure deployment (App Service, Functions, Agents Toolkit), AWS deployment (Lambda, API Gateway, ECS, SAM), Azure CLI reference, AWS CLI reference. + +### Security Domain (2 files) +Covers: input validation, secrets management. + +## How It Works + +1. **Developer sends a task** → root `index.md` interviews for scope and preferences +2. **Signal words are scanned** → task routes to exactly one domain router +3. **Domain router matches clusters** → loads only the relevant micro-expert files +4. **Expert-level interviews** (if present) → clarify implementation decisions +5. **Implementation** → expert rules, patterns, and pitfalls guide code generation + +## Eval Harness + +The [`evals/`](../evals/) directory contains an automated test harness that validates the expert system across three dimensions: + +| Dimension | What it checks | LLM required? | +|-----------|---------------|----------------| +| **Patterns** | TypeScript code blocks in experts still compile | No | +| **Routing** | User queries route to the correct domain/clusters/experts | Optional (improves accuracy) | +| **Completeness** | Experts cover all required concepts for their domain | Yes | + +```bash +cd evals && npm install +npm run eval:patterns # fast, no API key +npm run eval # all dimensions (needs OPENAI_API_KEY in .env) +``` + +Current results: 294/294 patterns compile, 41/51 routing cases pass (all 7 domains covered), 9/9 completeness cases pass. The ~10 routing failures are LLM judge scoring edge cases where the deterministic router is correct but the judge scores conservatively on ambiguous or cross-domain queries. See [`evals/README.md`](../evals/README.md) for details. + +After adding or editing experts, run `npm run eval:patterns` to verify code examples still compile. For new domains or significant expert changes, add test cases to `evals/cases/` and run the full suite. + +## Adding New Experts + +Use the `analyzer.md` → `builder.md` workflow: +1. Run `analyzer.md` against a codebase to identify coverage gaps +2. Hand off recommendations to `builder.md` to create expert files +3. New experts auto-wire into domain routers via the post-creation checklist in `_expert-ts.md` +4. Run `cd evals && npm run eval:patterns` to verify new code examples compile diff --git a/plugins/microsoft-365-agents-toolkit/skills/teams-app-developer/experts/_expert-ts.md b/plugins/microsoft-365-agents-toolkit/skills/teams-app-developer/experts/_expert-ts.md new file mode 100644 index 0000000..bd8a15c --- /dev/null +++ b/plugins/microsoft-365-agents-toolkit/skills/teams-app-developer/experts/_expert-ts.md @@ -0,0 +1,65 @@ +# {topic}-ts + +\## purpose + +{One-line description of what this expert covers.} + +\## rules + +1. {Core rule or pattern #1.} +2. {Core rule or pattern #2.} +3. {Add or remove rules as needed.} + +\## interview (optional — delete if not needed) + + + +\### Q1 — {Decision Topic} +``` +question: "{Clear question ending with ?}" +header: "{Short label, max 12 chars}" +options: + - label: "{Option A} (Recommended)" + description: "{What this option means and effort/tradeoff}" + - label: "{Option B}" + description: "{What this option means and effort/tradeoff}" + - label: "You Decide Everything" + description: "Accept recommended defaults for all decisions and skip remaining questions." +multiSelect: false +``` + +\### defaults table (required if interview exists) + +| Question | Default | +|---|---| +| Q1 | {Option A — the recommended choice} | + +\## instructions + +Do a web search for: + +\- "{SDK or library name} {specific API or concept} TypeScript {additional keywords}" + +\## research + +Deep Research prompt: + +"{Write a micro expert on {topic} in {SDK/platform} (TypeScript). Cover {key areas}. Include canonical patterns for: {pattern list}.}" + +--- + +\## post-creation checklist + +After creating a new expert from this template, you MUST complete these steps: + +1. **Add to domain `index.md`** — Open the domain's `index.md` (e.g., `teams/index.md`). Either: + - Append the new file to an existing task cluster's `Read:` list, OR + - Create a new task cluster with a `When:` description and `Read:` entry. + - Append the filename to the `## file inventory` list (alphabetical order). + +2. **Update root `index.md` signals** — If the new expert introduces signal words not already covered by the domain's signals list in `.experts/index.md`, add them to the domain's `Signals:` line. + +3. **Verify** — Confirm the file appears in both the domain `index.md` file inventory and the appropriate task cluster `Read:` list. diff --git a/plugins/microsoft-365-agents-toolkit/skills/teams-app-developer/experts/analyzer.md b/plugins/microsoft-365-agents-toolkit/skills/teams-app-developer/experts/analyzer.md new file mode 100644 index 0000000..c9302d5 --- /dev/null +++ b/plugins/microsoft-365-agents-toolkit/skills/teams-app-developer/experts/analyzer.md @@ -0,0 +1,160 @@ +# analyzer + +## purpose + +Scan a project codebase, identify its technology stack, and recommend micro-experts to create based on coverage gaps against the existing `.experts/` inventory. + +## rules + +1. **Scan manifests first.** Start with package manifests and lock files — they reveal the full dependency tree in seconds. Priority order: `package.json` / `package-lock.json` / `yarn.lock` / `pnpm-lock.yaml`, `Cargo.toml` / `Cargo.lock`, `go.mod` / `go.sum`, `pyproject.toml` / `requirements.txt` / `Pipfile`, `pom.xml` / `build.gradle`, `*.csproj` / `*.sln`, `Gemfile`, `Package.swift`, `build.gradle.kts`. +2. **Examine directory structure for framework signals.** Look for conventional directories: `src/app/` or `app/` (Next.js/Remix), `src/routes/` (SvelteKit), `pages/` (Next.js Pages Router), `components/`, `middleware/`, `migrations/`, `prisma/`, `terraform/`, `.github/workflows/`, `.circleci/`, `docker/`, `k8s/`, `helm/`. +3. **Read config files for tooling signals.** Check for: `tsconfig.json`, `.eslintrc.*`, `.prettierrc`, `jest.config.*`, `vitest.config.*`, `playwright.config.*`, `cypress.config.*`, `.dockerignore`, `Dockerfile`, `docker-compose.yml`, `nginx.conf`, `webpack.config.*`, `vite.config.*`, `tailwind.config.*`, `.env.example`. +4. **Catalog the full tech stack.** Produce a structured inventory: language(s), framework(s), build tool(s), test framework(s), CI/CD platform(s), infrastructure/deployment tool(s), notable libraries (ORM, HTTP client, state management, etc.). +5. **Cross-reference against existing `.experts/` inventory.** Read every domain `index.md` and list all expert files. Map each technology in the stack to the expert(s) that cover it. Mark technologies with no expert coverage as gaps. +6. **Score gaps by usage frequency and impact.** A framework used across every file (React, Express) scores higher than a dev-only tool used in one config file (Husky). Prioritize gaps that affect daily development decisions. +7. **Route library/framework experts to `languages/{lang}/libraries/`, not `.project/`.** When a gap is a language-specific framework or library (Next.js, Django, Spring Boot, Axum, etc.), place the expert under the relevant language's `libraries/` subfolder — e.g., `languages/typescript/libraries/nextjs.md`. This keeps framework knowledge co-located with the language it's written in and lets the language router load it alongside idioms and patterns. Reserve `.project/` for truly cross-cutting project-specific concerns that don't belong to a single language (CI/CD pipelines, infrastructure, project-specific workflows, multi-language prompt template conventions). +8. **Distinguish recommendation types.** Group into three categories: (a) **Populate stubs** — existing expert files that are placeholders; (b) **New project experts** — topics specific to this codebase's stack (frameworks, ORMs, CI/CD, etc.); (c) **General expert gaps** — topics that would benefit the general system (flag these but don't auto-create; they require broader applicability review). +9. **Output structured recommendations.** Each recommendation must include: filename, target domain (`languages/{lang}/libraries/` for language-specific frameworks, `.project/` for cross-cutting concerns), evidence (which files/deps triggered it), priority (high/medium/low), and a one-line expert purpose. +10. **Prioritize populating existing stubs over creating new experts.** Stubs represent already-identified knowledge gaps that the system is designed to hold. Filling them first maximizes coverage per effort. +11. **Update the target domain's `index.md` as experts are created.** After each expert is built, add it to the appropriate router's task clusters and file inventory. For library experts, update `languages/{lang}/libraries/index.md`. For cross-cutting experts, update `.project/index.md`. Keep routers current so the system can find new experts. +12. **Pair output with builder.md for handoff.** The analyzer identifies *what* to build; builder.md handles *how* to build it. Format recommendations so they can be directly fed into builder.md's Phase 1 scoping with the target domain pre-filled (`languages/{lang}/libraries/` or `.project/`). +13. **Scan for prompt template patterns.** Projects that use LLMs almost always have a prompt templating layer — and the implementation varies wildly. Scan for: LLM SDK imports (OpenAI, Anthropic, Azure OpenAI, LangChain, LlamaIndex, Semantic Kernel, Vercel AI SDK, etc.), prompt file conventions (a `prompts/` or `templates/` directory, `.prompt`, `.hbs`, `.jinja2`, `.mustache` files containing LLM instructions), string construction patterns (template literals, f-strings, or concatenation building system/user messages), and prompt management utilities (helper functions that assemble, format, or inject variables into prompts). When any of these signals are found, recommend a prompt template expert that documents: where templates live, which templating mechanism is used, how variables are injected, how system/user/assistant messages are constructed, and which LLM SDK the project calls. If the project uses a single language for LLM calls, place the expert under `languages/{lang}/libraries/prompt-templates.md`. If prompt construction spans multiple languages, place it under `.project/prompt-templates.md`. This expert pairs with `tools/prompt-engineer.md` — the general expert provides the design principles, the project expert provides the local conventions. +14. **Score prompt template gaps as high priority when LLM usage is core.** If the project's primary purpose involves LLM calls (an AI agent, a chatbot, a RAG pipeline, a prompt-driven workflow), the prompt template expert is high priority — it affects nearly every feature. If LLM calls are peripheral (e.g., a single summarization endpoint in a larger app), score it as medium. + +## patterns + +### Manifest scanning sequence + +``` +1. List root directory → identify project type +2. Read primary manifest: + - Node.js → package.json (dependencies, devDependencies, scripts) + - Rust → Cargo.toml (dependencies, features) + - Go → go.mod (require, module path) + - Python → pyproject.toml or requirements.txt + - Java → pom.xml or build.gradle + - C# → *.csproj (PackageReference) + - Ruby → Gemfile +3. Read secondary signals: + - CI/CD → .github/workflows/*.yml, .gitlab-ci.yml, Jenkinsfile + - Infra → Dockerfile, docker-compose.yml, terraform/, k8s/ + - Config → tsconfig.json, .eslintrc.*, vite.config.*, etc. +4. Scan src/ structure for framework conventions +5. Check for monorepo signals: workspaces, lerna.json, nx.json, turbo.json +``` + +### Prompt template scanning sequence + +``` +1. Check for LLM SDK dependencies in manifests: + - Node.js → openai, @anthropic-ai/sdk, @azure/openai, langchain, + llamaindex, @ai-sdk/*, semantic-kernel + - Python → openai, anthropic, langchain, llama-index, + semantic-kernel, guidance, promptflow + - C# → Azure.AI.OpenAI, Anthropic, Microsoft.SemanticKernel, + Microsoft.Extensions.AI + - Go → github.com/sashabaranov/go-openai, github.com/anthropics/... + - Rust → async-openai, anthropic-rs + +2. Scan for prompt file conventions: + - Directories: prompts/, templates/, agents/, instructions/ + - File types: *.prompt, *.txt, *.md, *.hbs, *.jinja2, *.mustache, + *.liquid containing LLM instructions + - Naming: *system*, *prompt*, *agent*, *instruction* in filenames + +3. Scan source code for prompt construction patterns: + - Template literals / f-strings building message content + - System/user/assistant role message arrays + - Section tag patterns: style markers + - Variable interpolation: {{var}}, {var}, ${var}, {{ var }} + - Prompt builder/formatter utility functions or classes + +4. Identify the prompt architecture: + - Storage: files on disk, inline in code, database, CMS + - Templating: native string interpolation, Handlebars, Jinja2, + Mustache, Liquid, custom + - Structure: section tags, markdown headers, XML tags, plain text + - Multi-turn: message array construction, conversation history mgmt + - Variables: how context is injected (retrieval, user input, state) + +5. Catalog findings for the project prompt template expert: + - SDK + client setup pattern + - Where templates live (path conventions) + - Templating mechanism + variable syntax + - Message construction pattern (system/user/assistant) + - Section/structure conventions used in prompts +``` + +### Coverage gap output template + +```markdown +## Expert Coverage Analysis + +### Tech Stack +| Category | Technology | Version | +|-------------|-----------------|----------| +| Language | TypeScript | 5.x | +| Framework | Next.js | 14.x | +| ORM | Prisma | 5.x | +| Testing | Vitest | 1.x | +| CI/CD | GitHub Actions | — | + +### Coverage Map +| Technology | Expert Coverage | Status | +|-----------------|------------------------------|--------| +| TypeScript | languages/typescript/*.md | ✅ Full | +| Git workflows | tools/git.md | ✅ Full | +| Prompt design | tools/prompt-engineer.md | ✅ General | +| Next.js | — | ❌ Gap | +| Prisma | — | ❌ Gap | +| Prompt templates | — | ❌ Gap (project-specific) | +| Vitest | — | ❌ Gap | +| GitHub Actions | — | ❌ Gap | + +### Recommendations +| # | File | Domain | Priority | Purpose | +|---|----------------------|---------------------------------|----------|--------------------------------------------| +| 1 | nextjs.md | languages/typescript/libraries/ | High | Next.js App Router patterns and conventions | +| 2 | prisma.md | languages/typescript/libraries/ | High | Prisma schema design, queries, migrations | +| 3 | prompt-templates.md | .project/ | High | Project prompt template conventions, SDK patterns, variable injection (pairs with tools/prompt-engineer.md) | +| 4 | vitest.md | languages/typescript/libraries/ | Medium | Vitest configuration and testing patterns | +| 5 | github-actions.md | .project/ | Medium | GitHub Actions workflow patterns (cross-cutting, not language-specific) | +``` + +### Builder.md handoff + +After generating recommendations, offer to start building: + +``` +To create any of these experts, I'll hand off to builder.md with the +scoping already pre-filled: + + "Create expert: {filename} in {target domain} — {purpose}. + Evidence: {manifest signals}. Priority: {level}." + +Which experts should I create? (Select numbers, or "all high priority") +``` + +## pitfalls + +- **Don't recommend experts for one-off dependencies.** A single `lodash` import or a `chalk` dependency doesn't warrant an expert. Focus on technologies that shape architectural decisions and daily workflows. +- **`devDependencies` don't always mean active use.** Many projects accumulate unused dev dependencies. Cross-reference with config files and import statements before recommending experts based on devDependencies alone. +- **Stubs are not coverage.** An expert file that exists but contains only a research prompt (stub) provides zero guidance. Count stubs as gaps when assessing coverage. +- **Prioritize ruthlessly in monorepos.** A monorepo with 50 packages and 20 technologies needs 4-6 high-impact experts, not 20. Focus on shared technologies that affect the most packages. +- **Don't confuse project-specific config with general expertise.** A project's custom webpack config doesn't need an expert — webpack itself might. Experts cover reusable knowledge, not project-specific setup. +- **Don't skip prompt template scanning because "it's just strings."** Prompt construction is often spread across utility functions, config files, and inline code with no obvious directory convention. Projects that use LLMs always have prompt patterns — they're just not always in a `prompts/` folder. Search SDK imports and message construction calls, not just file names. +- **Don't duplicate `tools/prompt-engineer.md` in the project expert.** The project prompt template expert captures *how this project* builds prompts (file locations, template syntax, SDK setup, variable injection). The general `prompt-engineer.md` expert covers *how to design prompts well*. The project expert should reference and pair with the general expert, not restate its principles. + +## instructions + +Use this expert when the developer wants to assess their project's technology stack and identify which micro-experts would be most valuable to create. + +**Trigger phrases:** "explore the codebase," "recommend experts," "analyze project," "audit experts," "expert coverage," "gap analysis," "what experts should I create," "scan my project." + +Pair with: `builder.md` for creating the recommended experts. The analyzer produces the roadmap; the builder executes it. Pair with: `tools/prompt-engineer.md` — when a project prompt template expert is created in `.project/`, it should declare `Pair with: tools/prompt-engineer.md` so the general prompt design principles are loaded alongside the project-specific conventions. + +## research + +Deep Research prompt: + +"Write a meta-expert for scanning software project codebases and recommending micro-experts to create. Cover: manifest file scanning strategies (package.json, Cargo.toml, go.mod, pyproject.toml, pom.xml, *.csproj, Gemfile), directory structure analysis for framework detection, config file signals for tooling identification, tech stack cataloging methodology, coverage gap analysis against an existing expert inventory, recommendation prioritization (usage frequency, architectural impact, daily development relevance), structured output formats for recommendations, handoff protocol to an expert-building workflow, and common analysis pitfalls (one-off deps, unused devDependencies, monorepo sprawl, stubs vs coverage)." diff --git a/plugins/microsoft-365-agents-toolkit/skills/teams-app-developer/experts/bridge/app-distribution-packaging-ts.md b/plugins/microsoft-365-agents-toolkit/skills/teams-app-developer/experts/bridge/app-distribution-packaging-ts.md new file mode 100644 index 0000000..c08dc33 --- /dev/null +++ b/plugins/microsoft-365-agents-toolkit/skills/teams-app-developer/experts/bridge/app-distribution-packaging-ts.md @@ -0,0 +1,190 @@ +# app-distribution-packaging-ts + +## purpose + +Bridges Slack App Directory distribution and Teams app packaging / Admin Center publishing for cross-platform bots targeting Slack, Teams, or both. + +## rules + +1. **Slack App Directory → Teams App Store (Partner Center).** Slack apps are listed in the Slack App Directory for public distribution. Teams apps are published to the Microsoft Teams App Store via Partner Center. The review and submission process is completely different — Partner Center requires a Microsoft Partner Network account and compliance with Teams store validation policies. [learn.microsoft.com -- Publish to store](https://learn.microsoft.com/en-us/microsoftteams/platform/concepts/deploy-and-publish/appsource/publish) +2. **Slack OAuth install flow → Azure Bot registration (no per-workspace tokens).** Slack apps use OAuth to install into each workspace, generating per-workspace `xoxb-` tokens stored in an `InstallationStore`. Teams bots use Azure Bot Framework credentials (`CLIENT_ID`/`CLIENT_SECRET`) that work across all tenants. There are no per-workspace tokens to manage. Delete `InstallationStore` and all OAuth install flow code. [learn.microsoft.com -- Bot registration](https://learn.microsoft.com/en-us/azure/bot-service/bot-service-quickstart-registration) +3. **Slack `InstallationStore` → conversation reference storage.** Slack's `InstallationStore` persists tokens per workspace for API calls. Teams doesn't need per-workspace tokens, but you still need to store conversation references for proactive messaging. Replace `InstallationStore` with a conversation reference store keyed by `conversationId`. [learn.microsoft.com -- Proactive messages](https://learn.microsoft.com/en-us/microsoftteams/platform/bots/how-to/conversations/send-proactive-messages) +4. **Slack org-level install → Teams Admin Center tenant-wide deployment.** Slack Enterprise Grid supports org-level app installation. In Teams, tenant-wide deployment is done via the Teams Admin Center by an IT admin: Manage Apps → Upload/Approve → Deploy to users/groups. No code changes needed — the admin controls distribution. [learn.microsoft.com -- Admin Center](https://learn.microsoft.com/en-us/microsoftteams/manage-apps) +5. **Development install → Teams sideloading.** Slack development apps are installed via the app's manage page or OAuth URL. Teams development apps are sideloaded: upload the app package (ZIP with manifest + icons) directly into Teams. Sideloading must be enabled by the tenant admin. [learn.microsoft.com -- Sideloading](https://learn.microsoft.com/en-us/microsoftteams/platform/concepts/deploy-and-publish/apps-upload) +6. **Agents Toolkit simplifies packaging, provisioning, and deployment.** Agents Toolkit (VS Code extension or CLI `atk`) automates: Azure resource provisioning, app package generation, sideloading, and publishing. It replaces the manual Azure Portal + zip file workflow. Use `atk package` to generate the app package and `atk publish` to submit. [learn.microsoft.com -- Agents Toolkit](https://learn.microsoft.com/en-us/microsoftteams/platform/toolkit/teams-toolkit-fundamentals) +7. **Multi-tenant Slack app → Azure AD multi-tenant app registration.** Slack multi-workspace apps use the App Directory + OAuth per workspace. Teams multi-tenant bots use a single Azure AD app registration with `signInAudience: "AzureADMultipleOrgs"`. Any tenant can install the bot without workspace-specific OAuth. [learn.microsoft.com -- Multi-tenant](https://learn.microsoft.com/en-us/azure/bot-service/bot-builder-authentication-basics) +8. **Slack app manifest (`manifest.json`) → Teams app manifest (`manifest.json` in app package).** Both platforms use JSON manifests but with completely different schemas. Slack's manifest includes OAuth scopes, event subscriptions, slash commands. Teams manifest includes `bots`, `composeExtensions`, `staticTabs`, `webApplicationInfo`, `validDomains`. No automatic conversion exists. [learn.microsoft.com -- Manifest schema](https://learn.microsoft.com/en-us/microsoftteams/platform/resources/schema/manifest-schema) +9. **Slack app icons (512x512 + workspace-specific) → Teams icons (color 192x192 + outline 32x32).** Teams requires exactly two icon files in the app package: a full-color icon (192x192 PNG) and an outline/monochrome icon (32x32 PNG with transparent background). The outline icon is used in the Teams activity bar. [learn.microsoft.com -- App icons](https://learn.microsoft.com/en-us/microsoftteams/platform/resources/schema/manifest-schema#icons) +10. **Slack app review (hours-days) vs Teams store review (1-2 weeks).** Slack's App Directory review is relatively fast. Teams App Store review via Partner Center is more rigorous and can take 1-2 weeks. Plan for revision cycles — common rejection reasons include missing privacy policy URL, incomplete manifest, and accessibility issues. [learn.microsoft.com -- Store validation](https://learn.microsoft.com/en-us/microsoftteams/platform/concepts/deploy-and-publish/appsource/prepare/teams-store-validation-guidelines) +11. **Reverse direction (Teams → Slack):** For Teams → Slack, map Teams manifest to Slack app manifest, and Teams Admin Center publishing to Slack App Directory submission. Azure Bot registration credentials map to Slack OAuth install flow with `InstallationStore` for per-workspace tokens. Teams sideloading maps to Slack development install via OAuth URL. The Teams color/outline icon pair maps to Slack's single 512x512 app icon. Azure AD multi-tenant registration maps to Slack App Directory multi-workspace distribution with per-workspace OAuth. + +## patterns + +### InstallationStore removal + conversation reference storage + +**Slack (before):** + +```typescript +import { App, Installation, InstallationQuery } from "@slack/bolt"; + +// InstallationStore — persist per-workspace tokens +const installationStore = { + storeInstallation: async (installation: Installation) => { + const teamId = installation.team?.id ?? installation.enterprise?.id; + await db.put(`installation:${teamId}`, JSON.stringify(installation)); + }, + fetchInstallation: async (query: InstallationQuery) => { + const teamId = query.teamId ?? query.enterpriseId; + const data = await db.get(`installation:${teamId}`); + return JSON.parse(data) as Installation; + }, + deleteInstallation: async (query: InstallationQuery) => { + const teamId = query.teamId ?? query.enterpriseId; + await db.delete(`installation:${teamId}`); + }, +}; + +const app = new App({ + signingSecret: process.env.SLACK_SIGNING_SECRET!, + clientId: process.env.SLACK_CLIENT_ID!, + clientSecret: process.env.SLACK_CLIENT_SECRET!, + stateSecret: process.env.SLACK_STATE_SECRET!, + installationStore, + scopes: ["chat:write", "commands", "channels:history"], +}); + +// Use workspace-specific token for API calls +app.message(/hello/i, async ({ say, client }) => { + // client automatically uses the workspace's xoxb token + await say("Hello!"); +}); +``` + +**Teams (after):** + +```typescript +import { App } from "@microsoft/teams.apps"; +import { ConsoleLogger } from "@microsoft/teams.common"; + +// No InstallationStore needed — single credential set works for all tenants +const app = new App({ + clientId: process.env.CLIENT_ID, // Azure Bot app ID + clientSecret: process.env.CLIENT_SECRET, // Azure Bot secret + tenantId: process.env.TENANT_ID, // or "common" for multi-tenant + logger: new ConsoleLogger("my-bot", { level: "info" }), +}); + +// Store conversation references instead of installations +// Needed for proactive messaging (the only thing that replaced InstallationStore's purpose) +const conversationRefs = new Map(); + +app.on("install.add", async ({ activity, send }) => { + // Persist conversation reference for future proactive messaging + const convId = activity.conversation?.id ?? ""; + conversationRefs.set(convId, { + conversationId: convId, + serviceUrl: (activity as any).serviceUrl, + tenantId: activity.channelData?.tenant?.id ?? "", + }); + await send("Bot installed! I'm ready to help."); +}); + +app.on("install.remove", async ({ activity }) => { + const convId = activity.conversation?.id ?? ""; + conversationRefs.delete(convId); +}); + +app.message(/hello/i, async ({ send }) => { + // No workspace token lookup needed — just send + await send("Hello!"); +}); + +app.start(3978); +``` + +### App Directory → Admin Center deployment + +**Slack** — submit app to Slack App Directory via api.slack.com dashboard. Users install via the directory. + +**Teams** — multiple distribution paths: + +```shell +# Option 1: Sideload for development +# Build the app package (manifest.json + icons in a ZIP) +atk package --env dev -i false + +# Upload to Teams: +# Teams → Apps → Manage your apps → Upload a custom app + +# Option 2: Submit to organization's app catalog +atk publish --env staging +# IT admin approves in Teams Admin Center → Manage Apps + +# Option 3: Submit to public Teams App Store (Partner Center) +# 1. Create Partner Center account +# 2. Submit app package for review +# 3. Review takes 1-2 weeks +# 4. Once approved, appears in Teams App Store + +# Option 4: Tenant-wide deployment (admin pushes to all users) +# Teams Admin Center → Manage Apps → find app → Assign to users/groups +# No code changes — purely admin configuration +``` + +**Teams app package structure:** + +``` +my-teams-bot.zip +├── manifest.json # Teams-specific manifest (not Slack's) +├── color.png # 192x192 full-color icon +└── outline.png # 32x32 monochrome outline icon +``` + +### Distribution model mapping table + +| Slack Distribution | Teams Equivalent | Notes | +|---|---|---| +| App Directory (public listing) | Teams App Store via Partner Center | Requires partner account; 1-2 week review | +| OAuth install flow (per-workspace) | Azure Bot registration (global) | No per-workspace tokens | +| `InstallationStore` | Conversation reference store | Only for proactive messaging | +| Org-level install (Enterprise Grid) | Teams Admin Center tenant-wide deploy | Admin pushes to users/groups | +| Development install (OAuth URL) | Sideloading (upload ZIP) | Admin must enable sideloading | +| `manifest.json` (Slack schema) | `manifest.json` (Teams schema) | Completely different schemas | +| App icon (512x512) | Color (192x192) + Outline (32x32) | Two icons required | +| OAuth scopes (`chat:write`, etc.) | Azure AD permissions + RSC | Different permission model | +| Multi-workspace (App Directory) | Multi-tenant (Azure AD) | `signInAudience: "AzureADMultipleOrgs"` | + +## pitfalls + +- **Trying to port the InstallationStore**: Teams does not need per-workspace token storage. Developers who port `InstallationStore` logic create unnecessary complexity. Delete it and use conversation reference storage only for proactive messaging. +- **Sideloading disabled by default in many orgs**: IT admins may have disabled sideloading. If the developer can't upload the app package, they need to request sideloading permission from their Teams admin. This is a common blocker during development. +- **Partner Center account setup takes time**: Publishing to the Teams App Store requires a Microsoft Partner Network account. Account verification can take days. Start the Partner Center registration early in the migration timeline. +- **Icon format rejection**: Teams requires exactly two PNG icons with specific dimensions. The outline icon must have a transparent background. Submitting icons in the wrong format or size causes app package validation failure. +- **Multi-tenant vs single-tenant confusion**: Slack apps are inherently multi-workspace when listed in the App Directory. Teams apps must explicitly set multi-tenant in the Azure AD app registration. A single-tenant registration only works in the developer's own organization. +- **OAuth scopes → RSC permissions**: Slack OAuth scopes (`channels:history`, `chat:write`) have no direct mapping to Azure AD permissions. Teams uses a combination of Azure AD API permissions and Resource-Specific Consent (RSC) permissions declared in the manifest. This is the most conceptually different part of the migration. + +## references + +- https://learn.microsoft.com/en-us/microsoftteams/platform/concepts/deploy-and-publish/appsource/publish +- https://learn.microsoft.com/en-us/microsoftteams/platform/concepts/deploy-and-publish/apps-upload +- https://learn.microsoft.com/en-us/microsoftteams/manage-apps +- https://learn.microsoft.com/en-us/microsoftteams/platform/toolkit/teams-toolkit-fundamentals +- https://learn.microsoft.com/en-us/microsoftteams/platform/resources/schema/manifest-schema +- https://learn.microsoft.com/en-us/azure/bot-service/bot-service-quickstart-registration +- https://learn.microsoft.com/en-us/microsoftteams/platform/concepts/deploy-and-publish/appsource/prepare/teams-store-validation-guidelines +- https://github.com/microsoft/teams.ts +- https://api.slack.com/distribution — Slack app distribution + +## instructions + +Use this expert when adding cross-platform support in either direction for app distribution and packaging. It covers: Slack App Directory bridged to Teams App Store (Partner Center), OAuth install flow vs Azure Bot registration, InstallationStore vs conversation reference storage, org-level deployment via Teams Admin Center, sideloading for development, Agents Toolkit for packaging, multi-tenant Azure AD registration, icon requirements, store review timelines, and reverse mapping from Teams manifest/Admin Center back to Slack app manifest and App Directory submission. Pair with `identity-oauth-bridge-ts.md` for the identity/OAuth model change, `../teams/runtime.manifest-ts.md` for Teams manifest creation, and `../teams/runtime.proactive-messaging-ts.md` for conversation reference storage patterns. + +## research + +Deep Research prompt: + +"Write a micro expert for bridging Slack App Directory distribution and Microsoft Teams app packaging / Admin Center publishing in either direction. Cover: App Directory vs Teams App Store (Partner Center), OAuth install flow vs Azure Bot registration, InstallationStore vs conversation reference storage, org-level install vs Teams Admin Center, sideloading, Agents Toolkit packaging, multi-tenant Azure AD app registration, icon requirements, manifest schema differences, OAuth scope to RSC mapping, store review timeline, and reverse mapping from Teams manifest/publishing back to Slack app manifest and App Directory submission. Include code examples and a mapping table." diff --git a/plugins/microsoft-365-agents-toolkit/skills/teams-app-developer/experts/bridge/channel-ops-graph-ts.md b/plugins/microsoft-365-agents-toolkit/skills/teams-app-developer/experts/bridge/channel-ops-graph-ts.md new file mode 100644 index 0000000..0adc6c3 --- /dev/null +++ b/plugins/microsoft-365-agents-toolkit/skills/teams-app-developer/experts/bridge/channel-ops-graph-ts.md @@ -0,0 +1,267 @@ +# channel-ops-graph-ts + +## purpose + +Bridges Slack channel operations (conversations.*) and Teams channel management via Microsoft Graph for cross-platform bots targeting Slack, Teams, or both. + +## rules + +1. **Slack `conversations.create` → Graph `POST /teams/{team-id}/channels`.** Slack creates channels in a flat workspace namespace. Teams channels always belong to a specific team — you must know the `team-id` first. The request body includes `displayName`, `description`, and `membershipType` (standard, private, shared). [learn.microsoft.com -- Create channel](https://learn.microsoft.com/en-us/graph/api/channel-post) +2. **Slack `conversations.archive` → no true archive in Teams.** Teams has no channel archive API. Workarounds: (a) delete the channel (destructive, 30-day soft delete), (b) rename the channel with a `[ARCHIVED]` prefix, (c) remove all members except owners, (d) for the entire team, use `POST /teams/{team-id}/archive`. Individual channel archival is not supported. [learn.microsoft.com -- Archive team](https://learn.microsoft.com/en-us/graph/api/team-archive) +3. **Slack `conversations.invite` → Graph `POST /teams/{team-id}/channels/{channel-id}/members`.** The request body must include the user's Azure AD Object ID (`@odata.type: '#microsoft.graph.aadUserConversationMember'`) and a `roles` array (`['owner']` or `[]` for member). Private channel membership is managed separately from standard channels. [learn.microsoft.com -- Add channel member](https://learn.microsoft.com/en-us/graph/api/channel-post-members) +4. **Slack `conversations.kick` → Graph `DELETE /teams/{team-id}/channels/{channel-id}/members/{membership-id}`.** You must first resolve the `membership-id` by listing channel members (`GET /teams/{team-id}/channels/{channel-id}/members`) and finding the member by their Azure AD Object ID. You cannot delete by user ID directly. [learn.microsoft.com -- Remove member](https://learn.microsoft.com/en-us/graph/api/channel-delete-members) +5. **Slack `conversations.setTopic` → Graph `PATCH /teams/{team-id}/channels/{channel-id}` with `description`.** Slack channels have a separate `topic` field. Teams channels use the `description` field as the closest equivalent. The channel name is updated via the `displayName` field. [learn.microsoft.com -- Update channel](https://learn.microsoft.com/en-us/graph/api/channel-patch) +6. **All channel operations require a `team-id`.** Slack has a flat channel namespace (every channel has a globally-unique `C-ID`). Teams channels are nested under teams. Most operations need both `team-id` and `channel-id`. Resolve team IDs via `GET /me/joinedTeams` or `GET /groups` with Teams filter. [learn.microsoft.com -- List joined teams](https://learn.microsoft.com/en-us/graph/api/user-list-joinedteams) +7. **Channel name restrictions differ from Slack.** Teams channel names cannot contain: `~ # % & * { } / \ : < > ? + | ' "`. Maximum length is 50 characters (Slack allows 80). Channel names must be unique within a team. Validate and sanitize names during migration. [learn.microsoft.com -- Channel limits](https://learn.microsoft.com/en-us/microsoftteams/limits-specifications-teams) +8. **Graph API requires application or delegated permissions.** Channel operations need `Channel.Create`, `ChannelMember.ReadWrite.All`, `Channel.Delete.All` (application permissions) or equivalent delegated permissions. These require Azure AD admin consent. Slack's bot token scopes (`channels:manage`, `channels:write.invites`) have no direct Azure AD equivalent. [learn.microsoft.com -- Graph permissions](https://learn.microsoft.com/en-us/graph/permissions-reference) +9. **Slack `conversations.list` → Graph `GET /teams/{team-id}/channels`.** List all channels in a team. For listing channels across teams, iterate over `GET /me/joinedTeams` first, then list channels per team. There is no single API to list all channels across all teams (unlike Slack's flat listing). [learn.microsoft.com -- List channels](https://learn.microsoft.com/en-us/graph/api/channel-list) +10. **Private channels have separate membership management.** Slack private channels (`is_private: true`) map to Teams private channels (`membershipType: 'private'`). Private channel members are managed via the channel members API, not the team membership. Adding a user to the team does NOT add them to private channels — you must add them to both. [learn.microsoft.com -- Private channels](https://learn.microsoft.com/en-us/microsoftteams/private-channels) +11. **Reverse direction (Teams → Slack):** For Teams → Slack, map Graph API channel operations to Slack's `conversations.*` API methods. `POST /teams/{team-id}/channels` maps to `conversations.create`. `POST /channels/{id}/members` maps to `conversations.invite`. `DELETE /channels/{id}/members/{id}` maps to `conversations.kick`. `PATCH /channels/{id}` with `description` maps to `conversations.setTopic`. Note that Slack has a flat channel namespace (no team-id required) and supports true channel archiving via `conversations.archive`. + +## patterns + +### Create channel + invite members + +**Slack (before):** + +```typescript +import { App } from "@slack/bolt"; + +const app = new App({ + token: process.env.SLACK_BOT_TOKEN!, + signingSecret: process.env.SLACK_SIGNING_SECRET!, +}); + +app.command("/create-channel", async ({ ack, command, client }) => { + await ack(); + const [name, ...memberIds] = command.text.split(" "); + + // Create channel in flat namespace + const channel = await client.conversations.create({ + name: name.toLowerCase().replace(/\s+/g, "-"), + is_private: false, + }); + + // Invite members by Slack user ID + if (memberIds.length > 0) { + await client.conversations.invite({ + channel: channel.channel!.id!, + users: memberIds.join(","), // comma-separated U-IDs + }); + } + + await client.chat.postMessage({ + channel: command.channel_id, + text: `Channel <#${channel.channel!.id}> created!`, + }); +}); +``` + +**Teams (after):** + +```typescript +import { App } from "@microsoft/teams.apps"; +import { ConsoleLogger } from "@microsoft/teams.common"; +import { Client } from "@microsoft/microsoft-graph-client"; + +const app = new App({ + logger: new ConsoleLogger("my-bot", { level: "info" }), +}); + +// Initialize Graph client (use app-only auth in production) +function getGraphClient(token: string): Client { + return Client.init({ + authProvider: (done) => done(null, token), + }); +} + +app.message(/^\/?create-channel (.+)$/i, async ({ send, activity }) => { + const args = activity.text?.replace(/^\/?create-channel\s+/i, "").split(" ") ?? []; + const [rawName, ...memberAadIds] = args; + + // Sanitize channel name for Teams restrictions + const channelName = rawName + .replace(/[~#%&*{}\/\\:<>?+|'"]/g, "") + .substring(0, 50); + + // Teams channels require a team-id (no flat namespace) + const teamId = activity.channelData?.team?.id; + if (!teamId) { + await send("This command must be run in a team context."); + return; + } + + const graphToken = await getAppOnlyToken(); + const graph = getGraphClient(graphToken); + + // Create channel under the team + const channel = await graph.api(`/teams/${teamId}/channels`).post({ + displayName: channelName, + description: `Created by bot on ${new Date().toISOString()}`, + membershipType: "standard", + }); + + // Invite members by Azure AD Object ID (not Slack U-ID) + for (const aadId of memberAadIds) { + await graph.api(`/teams/${teamId}/channels/${channel.id}/members`).post({ + "@odata.type": "#microsoft.graph.aadUserConversationMember", + "user@odata.bind": `https://graph.microsoft.com/v1.0/users('${aadId}')`, + roles: [], // empty = member, ['owner'] = owner + }); + } + + await send(`Channel **${channelName}** created with ${memberAadIds.length} members.`); +}); + +async function getAppOnlyToken(): Promise { + // Use @azure/identity ConfidentialClientApplication for production + return "..."; +} + +app.start(3978); +``` + +### Set topic + archive channel + +**Slack (before):** + +```typescript +import { App } from "@slack/bolt"; + +const app = new App({ + token: process.env.SLACK_BOT_TOKEN!, + signingSecret: process.env.SLACK_SIGNING_SECRET!, +}); + +app.command("/set-topic", async ({ ack, command, client }) => { + await ack(); + await client.conversations.setTopic({ + channel: command.channel_id, + topic: command.text, + }); + await client.chat.postMessage({ + channel: command.channel_id, + text: `Topic updated to: ${command.text}`, + }); +}); + +app.command("/archive-channel", async ({ ack, command, client }) => { + await ack(); + await client.conversations.archive({ + channel: command.channel_id, + }); + // Channel is now archived — no more messages can be posted +}); +``` + +**Teams (after):** + +```typescript +import { App } from "@microsoft/teams.apps"; +import { ConsoleLogger } from "@microsoft/teams.common"; +import { Client } from "@microsoft/microsoft-graph-client"; + +const app = new App({ + logger: new ConsoleLogger("my-bot", { level: "info" }), +}); + +function getGraphClient(token: string): Client { + return Client.init({ authProvider: (done) => done(null, token) }); +} + +// Set channel description (closest to Slack topic) +app.message(/^\/?set-topic (.+)$/i, async ({ send, activity }) => { + const topic = activity.text?.replace(/^\/?set-topic\s+/i, "") ?? ""; + const teamId = activity.channelData?.team?.id; + const channelId = activity.channelData?.channel?.id; + + if (!teamId || !channelId) { + await send("This command must be run in a team channel."); + return; + } + + const graph = getGraphClient(await getAppOnlyToken()); + await graph.api(`/teams/${teamId}/channels/${channelId}`).patch({ + description: topic, + }); + + await send(`Channel description updated to: ${topic}`); +}); + +// Archive channel — no direct equivalent, rename with prefix +app.message(/^\/?archive-channel$/i, async ({ send, activity }) => { + const teamId = activity.channelData?.team?.id; + const channelId = activity.channelData?.channel?.id; + + if (!teamId || !channelId) { + await send("This command must be run in a team channel."); + return; + } + + const graph = getGraphClient(await getAppOnlyToken()); + + // Get current channel name + const channel = await graph.api(`/teams/${teamId}/channels/${channelId}`).get(); + + // Rename with archive prefix (best available workaround) + await graph.api(`/teams/${teamId}/channels/${channelId}`).patch({ + displayName: `[ARCHIVED] ${channel.displayName}`.substring(0, 50), + description: `Archived on ${new Date().toISOString()}. ${channel.description ?? ""}`, + }); + + await send("Channel marked as archived. Note: Teams does not support true channel archival."); +}); + +async function getAppOnlyToken(): Promise { + return "..."; +} + +app.start(3978); +``` + +### Channel operation mapping table + +| Slack API | Graph API Equivalent | Notes | +|---|---|---| +| `conversations.create({ name })` | `POST /teams/{team-id}/channels` | Must specify team-id | +| `conversations.archive({ channel })` | *(no equivalent)* | Rename with prefix, or delete | +| `conversations.unarchive({ channel })` | *(no equivalent)* | Rename back | +| `conversations.invite({ channel, users })` | `POST /teams/{team-id}/channels/{id}/members` | One member per call; needs AAD Object ID | +| `conversations.kick({ channel, user })` | `DELETE /channels/{id}/members/{membership-id}` | Must resolve membership-id first | +| `conversations.setTopic({ channel, topic })` | `PATCH /channels/{id}` with `description` | Topic → description | +| `conversations.rename({ channel, name })` | `PATCH /channels/{id}` with `displayName` | 50 char limit | +| `conversations.list()` | `GET /teams/{team-id}/channels` | Per-team, not workspace-wide | +| `conversations.info({ channel })` | `GET /teams/{team-id}/channels/{id}` | Needs team-id | +| `conversations.members({ channel })` | `GET /teams/{team-id}/channels/{id}/members` | Returns AAD user objects | + +## pitfalls + +- **No flat channel namespace**: Slack's `C-ID` identifies a channel globally. Teams requires both `team-id` and `channel-id` for most operations. Bots must resolve or store the team context from `activity.channelData.team.id`. +- **Channel name validation**: Teams rejects names with special characters that Slack allows. Always sanitize channel names before creating. The `#` character — commonly used in Slack — is not allowed in Teams channel names. +- **Membership ID resolution for kicks**: You cannot remove a member by Azure AD Object ID alone. First list members, find the matching `conversationMember.id`, then delete by that membership ID. This is a two-API-call operation. +- **No true channel archive**: Slack's archive makes a channel read-only while preserving it. Teams has no equivalent. The rename-with-prefix workaround doesn't prevent new messages. True read-only requires deleting the channel (which has a 30-day recovery window). +- **Private channel membership is separate**: Adding a user to a team does NOT automatically add them to private channels. You must explicitly add them to each private channel. This differs from Slack where inviting to a private channel only requires the channel invite API. +- **Graph API rate limits**: Graph API has its own throttling (separate from Bot Framework). Bulk channel operations (creating many channels, inviting many users) should include retry logic with exponential backoff on HTTP 429 responses. +- **Admin consent required**: Application-level Graph permissions (`Channel.Create`, `ChannelMember.ReadWrite.All`) require Azure AD admin consent. This is a deployment-time concern — the bot code may work in dev but fail in production if admin consent hasn't been granted. + +## references + +- https://learn.microsoft.com/en-us/graph/api/channel-post +- https://learn.microsoft.com/en-us/graph/api/channel-post-members +- https://learn.microsoft.com/en-us/graph/api/channel-delete-members +- https://learn.microsoft.com/en-us/graph/api/channel-patch +- https://learn.microsoft.com/en-us/graph/api/team-archive +- https://learn.microsoft.com/en-us/graph/api/channel-list +- https://learn.microsoft.com/en-us/microsoftteams/limits-specifications-teams +- https://github.com/microsoft/teams.ts +- https://api.slack.com/methods/conversations.create — Slack conversations API + +## instructions + +Use this expert when adding cross-platform support in either direction for channel management operations. It covers: Slack `conversations.*` bridged to Graph API channel endpoints, `conversations.archive` workarounds in Teams, `conversations.invite` bridged to Graph member addition, `conversations.kick` with membership ID resolution, `conversations.setTopic` bridged to channel description update, team-id requirement, channel name restrictions, Graph API permission requirements, and reverse mapping from Graph channel operations back to Slack `conversations.*` methods. Pair with `../teams/graph.usergraph-appgraph-ts.md` for Graph API authentication, `identity-oauth-bridge-ts.md` for user ID mapping (Slack U-ID to AAD Object ID), and `rate-limiting-resilience-ts.md` for Graph API throttling patterns. + +## research + +Deep Research prompt: + +"Write a micro expert for bridging Slack channel management operations (conversations.create, conversations.archive, conversations.invite, conversations.kick, conversations.setTopic, conversations.list) and Microsoft Teams channel management via the Graph API in either direction. Cover: team-id requirement, channel name restrictions, private channel membership, the lack of channel archive API in Teams, membership ID resolution for removal, Graph API permissions, rate limiting, and reverse mapping from Graph operations back to Slack conversations.* methods. Include TypeScript code examples and a mapping table." diff --git a/plugins/microsoft-365-agents-toolkit/skills/teams-app-developer/experts/bridge/commands-slash-text-ts.md b/plugins/microsoft-365-agents-toolkit/skills/teams-app-developer/experts/bridge/commands-slash-text-ts.md new file mode 100644 index 0000000..d7441bb --- /dev/null +++ b/plugins/microsoft-365-agents-toolkit/skills/teams-app-developer/experts/bridge/commands-slash-text-ts.md @@ -0,0 +1,410 @@ +# commands-slash-text-ts + +## purpose + +Bridges Slack slash commands and Teams text commands / message extensions for cross-platform bots targeting Slack, Teams, or both. + +## rules + +1. Teams bots do **not** have a native slash command system equivalent to Slack's `app.command('/name')`. Slack slash commands must be reimplemented using one of three Teams patterns: text pattern matching, messaging extensions, or manifest command hints. [learn.microsoft.com -- Bots in Teams](https://learn.microsoft.com/en-us/microsoftteams/platform/bots/what-are-bots) +2. The most direct migration path is **text pattern matching** with `app.message(regex)` in the Teams SDK. Map `app.command('/help')` to `app.message(/^\/?help$/i)`. The leading `/?` makes the slash optional so users can type either "help" or "/help". [github.com/microsoft/teams.ts](https://github.com/microsoft/teams.ts) +3. Remove all `ack()` calls when migrating to Teams. Teams handlers do not require acknowledgement -- simply process the request and respond. The `ack` concept does not exist in the Teams SDK. [github.com/microsoft/teams.ts](https://github.com/microsoft/teams.ts) +4. Replace Slack's `respond()` (response_url) and `say()` with the Teams context methods `send()` (new message) and `reply()` (threaded reply). There is no Teams equivalent of Slack's ephemeral response -- all bot messages are visible to participants. [learn.microsoft.com -- Send proactive messages](https://learn.microsoft.com/en-us/microsoftteams/platform/bots/how-to/conversations/send-proactive-messages) +5. Slack's `trigger_id` for opening modals has no direct Teams equivalent. Instead, send an Adaptive Card with form inputs inline, or use a Task Module (dialog) opened via `dialog.open` handler. Task modules do not require a trigger_id -- they are opened by card actions or link unfurling. [learn.microsoft.com -- Task modules](https://learn.microsoft.com/en-us/microsoftteams/platform/task-modules-and-cards/what-are-task-modules) +6. For command **discoverability**, add entries to the `commands` array in the manifest's `bots` section. These appear as suggestions when users type in the bot's compose box. They are UI hints only -- the bot still receives the text as a regular message. [learn.microsoft.com -- Bot commands](https://learn.microsoft.com/en-us/microsoftteams/platform/bots/how-to/create-a-bot-commands-menu) +7. For a richer command UX, use **messaging extensions** (`composeExtensions` in manifest). Search-based extensions let users query and insert results; action-based extensions open a task module form. These replace complex slash commands that opened modals or returned structured data. [learn.microsoft.com -- Message extensions](https://learn.microsoft.com/en-us/microsoftteams/platform/messaging-extensions/what-are-messaging-extensions) +8. Slack's `command.text` (the argument string) maps to parsing `activity.text` in Teams. Strip the bot @mention prefix first (set `activity.mentions.stripText: true` in App options), then parse the remaining text for arguments. [github.com/microsoft/teams.ts](https://github.com/microsoft/teams.ts) +9. Slack's `command.user_id` maps to `activity.from.aadObjectId` (Azure AD Object ID) in Teams. Slack's `command.channel_id` maps to `activity.conversation.id`. These IDs have completely different formats and are not interchangeable. [learn.microsoft.com -- Activity schema](https://learn.microsoft.com/en-us/azure/bot-service/rest-api/bot-framework-rest-connector-api-reference) +10. In Teams channels, bots only receive messages when @mentioned (unless configured otherwise via RSC permissions). Slash commands in Slack work without mention. Account for this UX difference by instructing users to @mention the bot or by scoping command bots to personal chat where every message is delivered. [learn.microsoft.com -- Channel conversations](https://learn.microsoft.com/en-us/microsoftteams/platform/bots/how-to/conversations/channel-and-group-conversations) + +## patterns + +### Migrating a Slack slash command to Teams text pattern matching + +**Slack (before):** + +```typescript +import { App } from "@slack/bolt"; + +const app = new App({ + token: process.env.SLACK_BOT_TOKEN!, + signingSecret: process.env.SLACK_SIGNING_SECRET!, +}); + +app.command("/status", async ({ ack, command, respond }) => { + await ack("Checking status..."); + const status = await getSystemStatus(); + await respond({ + response_type: "in_channel", + text: `System status: ${status}`, + }); +}); +``` + +**Teams (after):** + +```typescript +import { App } from "@microsoft/teams.apps"; +import { ConsoleLogger } from "@microsoft/teams.common"; +import { DevtoolsPlugin } from "@microsoft/teams.dev"; + +const app = new App({ + logger: new ConsoleLogger("my-bot", { level: "info" }), + plugins: [new DevtoolsPlugin()], +}); + +// No ack() needed. Regex makes the leading slash optional. +app.message(/^\/?status$/i, async ({ send }) => { + const status = await getSystemStatus(); + // No ephemeral option -- all messages are visible + await send(`System status: ${status}`); +}); + +async function getSystemStatus(): Promise { + return "All systems operational"; +} + +app.start(3978); +``` + +### Migrating a command that opened a modal to a Teams Adaptive Card form + +**Slack (before):** + +```typescript +import { App } from "@slack/bolt"; + +const app = new App({ + token: process.env.SLACK_BOT_TOKEN!, + signingSecret: process.env.SLACK_SIGNING_SECRET!, +}); + +app.command("/ticket", async ({ ack, command, client }) => { + await ack(); + await client.views.open({ + trigger_id: command.trigger_id, + view: { + type: "modal", + callback_id: "ticket_modal", + title: { type: "plain_text", text: "Create Ticket" }, + submit: { type: "plain_text", text: "Create" }, + blocks: [ + { + type: "input", + block_id: "title_block", + label: { type: "plain_text", text: "Title" }, + element: { type: "plain_text_input", action_id: "title_input" }, + }, + ], + }, + }); +}); + +app.view("ticket_modal", async ({ ack, view, client }) => { + const title = view.state.values.title_block.title_input.value!; + await ack(); + await client.chat.postMessage({ + channel: "#tickets", + text: `New ticket: ${title}`, + }); +}); +``` + +**Teams (after) -- Adaptive Card inline form replaces modal:** + +```typescript +import { App } from "@microsoft/teams.apps"; +import { ConsoleLogger } from "@microsoft/teams.common"; +import { DevtoolsPlugin } from "@microsoft/teams.dev"; + +const app = new App({ + logger: new ConsoleLogger("my-bot", { level: "info" }), + plugins: [new DevtoolsPlugin()], +}); + +// User types "ticket" or "/ticket" to get the form card +app.message(/^\/?ticket$/i, async ({ send }) => { + await send({ + attachments: [ + { + contentType: "application/vnd.microsoft.card.adaptive", + content: { + type: "AdaptiveCard", + version: "1.5", + body: [ + { type: "TextBlock", text: "Create Ticket", weight: "Bolder", size: "Large" }, + { + type: "Input.Text", + id: "ticketTitle", + label: "Title", + placeholder: "Describe the issue", + isRequired: true, + errorMessage: "Title is required", + }, + { + type: "Input.ChoiceSet", + id: "ticketPriority", + label: "Priority", + value: "medium", + choices: [ + { title: "High", value: "high" }, + { title: "Medium", value: "medium" }, + { title: "Low", value: "low" }, + ], + }, + ], + actions: [ + { + type: "Action.Submit", + title: "Create", + data: { action: "createTicket" }, + }, + ], + }, + }, + ], + }); +}); + +// Handle the card form submission (replaces app.view handler) +app.on("card.action", async ({ activity, send }) => { + const data = activity.value?.action?.data ?? activity.value; + if (data?.action === "createTicket") { + const title = data.ticketTitle; + const priority = data.ticketPriority; + await send(`Ticket created: ${title} [${priority}]`); + return { status: 200 }; + } +}); + +app.start(3978); +``` + +### Migrating a data-lookup command to a search-based message extension + +**Slack (before):** + +```typescript +import { App } from "@slack/bolt"; + +const app = new App({ + token: process.env.SLACK_BOT_TOKEN!, + signingSecret: process.env.SLACK_SIGNING_SECRET!, +}); + +// User types: /lookup serverName +app.command("/lookup", async ({ ack, command, respond }) => { + await ack(); + const query = command.text; + const results = await searchServers(query); + if (results.length === 0) { + await respond({ response_type: "ephemeral", text: "No results found." }); + return; + } + await respond({ + response_type: "ephemeral", + blocks: results.map((r) => ({ + type: "section", + text: { type: "mrkdwn", text: `*${r.name}*\nStatus: ${r.status} | IP: ${r.ip}` }, + })), + }); +}); +``` + +**Teams (after) — search-based message extension:** + +```typescript +import { App } from "@microsoft/teams.apps"; +import { ConsoleLogger } from "@microsoft/teams.common"; + +const app = new App({ + logger: new ConsoleLogger("my-bot", { level: "info" }), +}); + +// Search-based message extension replaces /lookup +// Triggered from compose box or command bar in Teams +app.on("message.ext.query" as any, async ({ activity }) => { + const query = activity.value?.queryOptions?.searchText ?? ""; + const results = await searchServers(query); + + return { + status: 200, + body: { + composeExtension: { + type: "result", + attachmentLayout: "list", + attachments: results.map((r) => ({ + contentType: "application/vnd.microsoft.card.adaptive", + content: { + type: "AdaptiveCard", + version: "1.5", + body: [ + { type: "TextBlock", text: r.name, weight: "Bolder" }, + { type: "TextBlock", text: `Status: ${r.status} | IP: ${r.ip}`, isSubtle: true }, + ], + }, + preview: { + contentType: "application/vnd.microsoft.card.thumbnail", + content: { title: r.name, text: `${r.status} — ${r.ip}` }, + }, + })), + }, + }, + }; +}); + +async function searchServers(query: string) { + return [{ name: "web-prod-01", status: "healthy", ip: "10.0.1.5" }]; +} + +app.start(3978); +``` + +**Manifest `composeExtensions` config (required for message extensions):** + +```json +{ + "composeExtensions": [ + { + "botId": "${{BOT_ID}}", + "commands": [ + { + "id": "lookupServer", + "type": "query", + "title": "Lookup Server", + "description": "Search for servers by name", + "initialRun": false, + "parameters": [ + { + "name": "searchText", + "title": "Server name", + "description": "Search for a server", + "inputType": "text" + } + ] + } + ] + } + ] +} +``` + +### Adding manifest commands for discoverability + +```json +{ + "bots": [ + { + "botId": "${{BOT_ID}}", + "scopes": ["personal", "team", "groupChat"], + "commands": [ + { + "title": "status", + "description": "Check system status" + }, + { + "title": "ticket", + "description": "Create a new support ticket" + }, + { + "title": "help", + "description": "Show available commands" + } + ] + } + ] +} +``` + +**Command mapping reference table:** + +| Slack Pattern | Teams Equivalent | Notes | +|---|---|---| +| `app.command('/help', ...)` | `app.message(/^\/?help$/i, ...)` | Text matching; no ack needed | +| `ack()` / `ack(text)` | *(remove)* | Teams has no ack concept | +| `respond({ response_type: "in_channel" })` | `send(text)` | All Teams messages are visible | +| `respond({ response_type: "ephemeral" })` | *(no equivalent)* | Redesign as personal chat or card | +| `command.trigger_id` + `views.open()` | Adaptive Card form or `dialog.open` | No trigger_id in Teams | +| `command.text` | `activity.text` (after stripping @mention) | Parse arguments from message text | +| `command.user_id` (U-ID) | `activity.from.aadObjectId` (AAD GUID) | Different ID format | +| `command.channel_id` (C-ID) | `activity.conversation.id` | Different ID format | +| `command.response_url` | `send()` / `reply()` | Direct methods, no URL-based responses | +| Manifest: Slack app dashboard | Manifest: `bots[].commands[]` | JSON file instead of web UI | + +### Best practice: text matching + manifest commands together (Y1) + +Use **both** text pattern matching and manifest bot commands for the best UX. Manifest commands give discoverability (users see them in the command menu); text matching ensures the bot responds to both `/weather` and `weather` so users migrating from Slack don't retrain muscle memory. + +```typescript +// Accept both "/weather" and "weather" — regex makes slash optional +app.message(/^\/?weather$/i, async ({ send }) => { + const weather = await getWeather(); + await send(`Current weather: ${weather}`); +}); +``` + +**Manifest (add commands for discoverability):** + +```json +{ + "bots": [{ + "botId": "${{BOT_ID}}", + "scopes": ["personal", "team", "groupChat"], + "commands": [ + { "title": "weather", "description": "Check the current weather" }, + { "title": "status", "description": "Check system status" }, + { "title": "help", "description": "Show available commands" } + ] + }] +} +``` + +**Don't:** Create a message extension for every slash command. Reserve extensions for commands that benefit from rich search results or task module UI. + +**Reverse (Teams → Slack):** Register commands via `app.command("/name", handler)` with `await ack()`. Configure in the Slack app dashboard. + +### Reverse direction (Teams → Slack) + +For Teams → Slack, map `app.message(regex)` to `app.command('/name')`, add `ack()` calls, and convert Adaptive Card forms to Block Kit modals. Key reverse mappings: +- `app.message(/^\/?name$/i, ...)` → `app.command('/name', ...)` with `await ack()` at the top +- `send(text)` → `respond({ response_type: 'in_channel', text })` or `say(text)` +- `reply(text)` → `say({ text, thread_ts: message.ts })` +- Adaptive Card inline form → `views.open(trigger_id, view)` with Block Kit modal +- `app.on('card.action', ...)` with `data.action` routing → `app.view('callback_id', ...)` for modal submissions, `app.action('action_id', ...)` for button clicks +- Manifest `bots[].commands[]` → Slack App Dashboard slash command configuration +- `activity.from.aadObjectId` → `command.user_id` (requires ID mapping table) +- `activity.text` (after stripping @mention) → `command.text` (clean argument string) +- Message extensions (search-based) → slash commands returning ephemeral blocks, or external data source selects +- All visible messages → consider which should be `response_type: 'ephemeral'` for Slack's richer privacy model + +## pitfalls + +- **Expecting slash command UX in Teams**: Teams users do not get the same discoverable `/command` experience. Set expectations that commands are triggered by typing text or using the bot commands menu. +- **Forgetting to remove `ack()`**: Leaving `ack()` calls in migrated code causes runtime errors since the Teams context object has no `ack` method. +- **Not handling @mention prefix**: In Teams channels, `activity.text` includes the @mention text (e.g., `BotName status`). Set `activity.mentions.stripText: true` in App options or strip manually before matching. +- **Relying on ephemeral responses**: Slack commands can respond ephemerally. Teams has no ephemeral messages. Redesign private responses as personal (1:1) chat messages or use Adaptive Cards that only the acting user sees after refresh. +- **Ignoring the personal vs channel distinction**: In Slack, slash commands work identically in channels and DMs. In Teams, channel bots require @mention. Consider scoping command-heavy bots to personal chat for a smoother UX. +- **Missing manifest commands**: Without `commands` in the manifest, users have no way to discover what the bot supports. Always add command hints for discoverability. +- **Complex argument parsing**: Slack's `command.text` arrives as a clean string after the command name. In Teams, you must parse `activity.text` which may include the bot mention, extra whitespace, and varied formatting. +- **Missing `composeExtensions` in manifest**: Message extensions (search-based or action-based) require a `composeExtensions` entry in the Teams manifest. Without it, the extension never appears in the compose box or command bar. This is the most common reason message extensions silently fail to load. + +## references + +- https://learn.microsoft.com/en-us/microsoftteams/platform/bots/what-are-bots +- https://learn.microsoft.com/en-us/microsoftteams/platform/bots/how-to/create-a-bot-commands-menu +- https://learn.microsoft.com/en-us/microsoftteams/platform/messaging-extensions/what-are-messaging-extensions +- https://learn.microsoft.com/en-us/microsoftteams/platform/task-modules-and-cards/what-are-task-modules +- https://learn.microsoft.com/en-us/microsoftteams/platform/bots/how-to/conversations/channel-and-group-conversations +- https://github.com/microsoft/teams.ts +- https://slack.dev/bolt-js/concepts/commands +- https://api.slack.com/interactivity/slash-commands + +## instructions + +This expert covers bridging Slack slash commands and Teams text commands / message extensions. Use it when adding cross-platform support in either direction: converting `app.command()` handlers to Teams `app.message()` with regex patterns, or converting Teams text handlers back to Slack slash commands with `ack()` calls. It covers the three Teams alternatives to slash commands (text matching, messaging extensions, manifest commands), response pattern bridging (`respond`/`say` ↔ `send`/`reply`), modal/form bridging (`trigger_id` + `views.open` ↔ Adaptive Card forms / Task Modules), command payload property mapping, ephemeral message handling, and manifest command entries. Pair with `../slack/runtime.slash-commands-ts.md` for Slack command patterns, and `../teams/runtime.routing-handlers-ts.md` for Teams `app.message()` patterns. + +## research + +Deep Research prompt: + +"Write a micro expert for bridging Slack slash commands and Teams text commands / message extensions bidirectionally. Cover the three Teams alternatives (text pattern matching with app.message regex, messaging extensions, manifest bot commands), side-by-side code examples for bridging in both directions, payload property mapping (command.text <-> activity.text, trigger_id, response_url <-> send/reply), ack() addition/removal, ephemeral response handling, and manifest configuration. Include a mapping table and common pitfalls for both directions." diff --git a/plugins/microsoft-365-agents-toolkit/skills/teams-app-developer/experts/bridge/cross-platform-advisor-ts.md b/plugins/microsoft-365-agents-toolkit/skills/teams-app-developer/experts/bridge/cross-platform-advisor-ts.md new file mode 100644 index 0000000..5330e68 --- /dev/null +++ b/plugins/microsoft-365-agents-toolkit/skills/teams-app-developer/experts/bridge/cross-platform-advisor-ts.md @@ -0,0 +1,738 @@ +# cross-platform-advisor-ts + +## purpose + +Interactive cross-platform bridging advisor. Detects which platform(s) a bot already targets, determines the bridging direction, analyzes the codebase, and walks the developer through every YELLOW/RED bridging decision — with a "take all defaults" escape hatch on every question. + +## rules + +### Phase 0: Direction Detection + +1. **Detect the existing platform.** Scan the codebase in parallel for platform signatures: + + | Pattern to search | Platform detected | + |---|---| + | `@slack/bolt` or `require('slack')` or `app.command(` (Bolt-style) | Slack | + | `@microsoft/teams-ai` or `@microsoft/teams.apps` or `teamsBot` or `BotFrameworkAdapter` | Teams | + | `Block Kit` or `"type":"section"` or `blocks:` (Slack-style) | Slack | + | `AdaptiveCards` or `"type":"AdaptiveCard"` or `CardFactory` | Teams | + | `SLACK_BOT_TOKEN` or `SLACK_APP_TOKEN` or `socketMode` | Slack | + | `CLIENT_ID` + `CLIENT_SECRET` + `TENANT_ID` (Azure Bot) | Teams | + | `ack(` (Slack acknowledgement) | Slack | + | `app.on("message"` or `app.message(` (Teams AI style) | Teams | + +2. **Determine direction.** Based on what was found: + - **Slack only detected** → Direction is **Slack → Teams** (adding Teams support) + - **Teams only detected** → Direction is **Teams → Slack** (adding Slack support) + - **Both detected** → Dual-platform bot already exists. Ask what they want to do (extend, reconcile, or audit). + - **Neither detected** → Ask the developer which platform they're starting from. + +3. **Confirm with the developer.** Present the detected direction: + ``` + header: "Direction" + question: "I detected {platform} patterns in your codebase. Which direction are you bridging?" + options: + - label: "Add Teams to existing Slack bot (Recommended)" + description: "Keep Slack, add Teams as a second platform." + - label: "Add Slack to existing Teams bot" + description: "Keep Teams, add Slack as a second platform." + - label: "Audit existing dual-platform bot" + description: "Both platforms detected — review coverage and gaps." + ``` + + Adapt the recommended option to match what was detected. If Teams was detected, recommend "Add Slack." + +### Phase 1: Codebase Analysis + +4. **Scan for platform API usage.** Search the codebase for these patterns to build a feature inventory. Run all searches in parallel: + + **Slack patterns (relevant when Slack → Teams):** + + | Pattern to search | What it detects | Maps to | + |---|---|---| + | `app.command` | Slash commands | G7 | + | `app.message` | Message pattern matching | G1 | + | `say(` or `respond(` | Simple replies | G2 | + | `blocks:` or `Block Kit` or `"type":"section"` | Block Kit UI | G16 | + | `views.open` or `views.push` | Modals / stacking | G19, Y24 | + | `view_submission` or `viewSubmission` | Modal submission | G20 | + | `app.use(` | Middleware | G14 | + | `ack(` | Slack acknowledgement | G15 | + | `chat.postEphemeral` or `response_type.*ephemeral` | Ephemeral messages | Y1, R1 | + | `reply_broadcast` or `broadcast.*true` | Thread broadcast | Y2 | + | `conversations.replies` | Thread discovery | Y3 | + | `files.upload` or `file_shared` | File upload | Y4/5/6 | + | `link_shared` or `chat.unfurl` | Link unfurling | Y7 | + | `scheduleMessage` or `chat.schedule` | Scheduled messages | Y8, R7 | + | `reminders.add` | Reminders | Y9 | + | `conversations.archive` | Channel archive | Y10, R8 | + | `conversations.kick` or `conversations.invite` | Channel member mgmt | Y11 | + | `app.shortcut` or `global_shortcut` | Global shortcuts | Y13 | + | `message_shortcut` | Message shortcuts | Y14 | + | `block_suggestion` or `app.options` | Dynamic selects | Y15 | + | `app_home_opened` or `views.publish` | App Home | Y16 | + | `view_hash` or `hash` (in modal context) | View hash / race cond | Y17 | + | `blockAction` (inside modals) | Mid-form updates | R4 | + | `ack.*errors` or `response_action.*errors` | Field validation | R5 | + | `notify_on_close` or `view_closed` | Cancel notification | R3 | + | `workflow_step` or `workflow_step_execute` | Workflow Builder | Y12 | + | `reaction_added` or `reaction_removed` | Emoji reactions | R2 | + | `SLACK_APP_TOKEN` or `socketMode` or `SocketModeReceiver` | Socket Mode | Y19 | + | `retryConfig` or `retry` (in Bolt config) | Built-in retry | Y20 | + | `confirm:` or `"confirm"` (on button/action) | Confirmation dialogs | Y21 | + | `*.example.com` in manifest or unfurl config | Unfurl wildcards | Y23 | + | `conversations.create` or `conversations.setTopic` | Channel ops | Y10/Y11 | + + **Teams patterns (relevant when Teams → Slack):** + + | Pattern to search | What it detects | Slack equivalent | + |---|---|---| + | `app.on("message"` or `activity.text` | Message handling | `app.message` | + | `AdaptiveCard` or `CardFactory.adaptiveCard` | Adaptive Cards | Block Kit | + | `app.on("dialog"` or `taskModule` | Task module / dialog | `views.open` modal | + | `proactiveMessage` or `continueConversation` | Proactive messaging | `chat.postMessage` to channel | + | `app.on("messageReaction"` | Reaction events | `reaction_added` | + | `refresh.userIds` | Per-user cards | Ephemeral messages | + | `MessageExtension` or `composeExtension` | Message extensions | Shortcuts | + | `tab.fetch` or `tab.submit` | Personal tabs | App Home | + | `Graph` or `graphClient` | Microsoft Graph calls | Slack Web API | + | `SSO` or `oauth` (Teams context) | SSO / OAuth | Slack OAuth | + | `FileConsentCard` or `supportsFiles` | File consent flow | `files.upload` | + | `messageHandlers` (in manifest) | Link unfurling | `link_shared` | + | `ChannelMessage.Read.Group` (RSC) | All channel messages | Default in Slack | + +5. **Build the feature list.** From scan results, produce a table: `Feature | Found (Y/N) | File:Line | Feature ID`. Only include features where code evidence was found. + +6. **Determine the bot profile.** Use the feature list to classify: + - **Profile A** — Only GREEN features found (G1–G34) + - **Profile B** — GREEN + YELLOW from: Y1, Y2, Y3, Y4/5/6, Y17, Y18, Y21 + - **Profile C** — Profile B + any of: Y7, Y8, Y9, Y10, Y11, Y13, Y14, Y15, Y16, Y23, Y24 + - **Profile D** — Profile C + any of: Y12, Y19, Y20, Y22, or any RED feature is core + + Note: For Teams → Slack direction, the profile classification still applies — the feature IDs map to equivalent complexity tiers in the reverse direction. + +7. **Present the profile.** Show the developer: + - Their detected profile (A/B/C/D) + - The bridging direction (Slack → Teams or Teams → Slack) + - The feature inventory table + - Which phases from the bridging sequence apply (reference `MigrationDecisionMatrix.md` Section 2) + - How many YELLOW and RED decisions they need to make + +### Phase 2: Decision Walkthrough + +8. **Ask one decision at a time.** For each YELLOW/RED feature found in the codebase, present a question using `AskUserQuestion`. Walk through decisions in phase order (matching the bridging phase sequence), not alphabetically. + +9. **Decision ordering.** Present decisions in this order (skip any not found in codebase): + + **Phase 5 — Interactive Responses:** + Y1 (Ephemeral), Y21 (Confirmation dialogs), Y17 (View hash) + + **Phase 7 — Files + Unfurling:** + Y4/5/6 (File upload), Y7 (Link unfurling), Y23 (Unfurl wildcards) + + **Phase 8 — Scheduling + Channel Ops:** + Y8 (Scheduled messages), Y9 (Reminders), Y10 (Channel archive), Y11 (Channel member removal) + + **Phase 9 — Shortcuts + App Home:** + Y13 (Global shortcuts), Y14 (Message shortcuts), Y15 (Dynamic selects), Y16 (App Home), Y24 (Multi-step modals) + + **Phase 10 — Workflows + Distribution:** + Y12 (Workflow Builder), Y22 (App Directory) + + **Phase 11 — Resilience:** + Y18 (All channel messages), Y19 (Socket Mode), Y20 (Retry) + + **Message handling (parallel with Phase 5):** + Y2 (reply_broadcast), Y3 (Thread discovery) + + **RED features (after all YELLOW):** + R1 (True ephemeral), R2 (Emoji reactions), R3 (viewClosed), R4 (Mid-form dynamic), R5 (Field validation), R6 (Dialog stacking), R7 (Scheduled API), R8 (Channel archive), R9 (Retroactive unfurl), R10 (Firewall transport) + + Note: For Teams → Slack direction, adapt the questions to reflect adding Slack equivalents. The same feature IDs apply but the "source" and "target" swap. For example, Y1 becomes "Your bot uses refresh.userIds — Slack supports true ephemeral messages via chat.postEphemeral. Use it directly." + +10. **Every question gets an escape hatch.** The final option in every `AskUserQuestion` call MUST be one of: + - First question: **"You Decide Everything"** — accept all defaults for ALL decisions (YELLOW + RED), skip remaining questions, jump to Phase 3. + - Subsequent questions: **"You Decide Everything Else"** — accept defaults for all REMAINING decisions, skip remaining questions, jump to Phase 3. + + When the developer picks either escape hatch, record all remaining features as "default" and proceed to Phase 3 immediately. + +11. **Question format for YELLOW features.** Each `AskUserQuestion` must include: + - `header`: Feature ID (e.g., "Y1 Ephemeral") + - `question`: Clear question about which approach they prefer (adapted for bridging direction) + - Options from `MigrationDecisionMatrix.md` Section 3, with the **(Recommended)** option listed first + - Final option: the escape hatch + +12. **Question format for RED features.** Each `AskUserQuestion` must include: + - `header`: Feature ID (e.g., "R4 Dynamic") + - `question`: What they want to do about the platform gap (adapted for bridging direction) + - Options matching the strategies from `MigrationDecisionMatrix.md` Section 4 + - Final option: the escape hatch + +13. **Record every decision.** Maintain a running decisions table as you go: + + | Feature | Decision | Option | Notes | + |---|---|---|---| + | Y1 Ephemeral | `refresh.userIds` | A (Recommended) | — | + | Y4/5/6 Files | `sendFile()` helper | B (Recommended) | Default accepted | + | ... | ... | ... | ... | + +### Phase 3: Bridging Plan Output + +14. **Generate the bridging plan.** After all decisions are made (or defaults accepted), produce a single actionable plan with: + + - **Direction** — Which platform exists, which is being added + - **Profile summary** — Profile letter, feature count, phase count + - **Decisions summary** — The completed decisions table + - **Phase-by-phase implementation order** — For each applicable phase: + - Which expert(s) to load: `.experts/bridge/{filename}` + - What to implement + - Which decision applies (if any) + - Go/no-go gate from `MigrationDecisionMatrix.md` Section 2 + - **Helpers to build** — List of helper utilities/plugins chosen (e.g., `sendFile()`, `RetryPlugin`), grouped as a "Phase 0" pre-work step + - **RED feature workarounds** — For each RED feature, the chosen strategy and implementation approach + - **Estimated phase count** — Total phases and which can be parallelized + +15. **Always reference, never duplicate.** Point developers to the specific expert files for implementation details. Do NOT reproduce the code patterns from individual experts — just reference them by filename and rule number. + +### Phase 4: Per-Project Implementation Order + +When implementing each bridged project (whether a single sample or a batch), follow this exact sequence. Do NOT skip steps or reorder them. + +16. **Step 1 — Write all source files.** Write every file the project needs before running any commands: + - `package.json` — dependencies, scripts (`build`, `start`, `dev`) + - `tsconfig.json` — TypeScript compiler config + - `src/index.ts` — main entry point (and any additional `.ts` files) + - `.env.sample` — template with placeholder values for all required env vars + - Stub implementations — where an API is not yet wired up, leave a clearly marked `// TODO:` with an explanation of what should go there so the code still compiles. + +17. **Step 2 — Install dependencies.** Run `npm install` in the project directory. Verify `node_modules` is created and there are no install errors. + +18. **Step 3 — Build and verify.** Run `npm run build`. Must succeed with **zero TypeScript errors**. Fix any issues before proceeding. + +19. **Step 4 — Create app manifest.** + - **Adding Teams:** Create `appPackage/` directory with `manifest.json` (schema v1.19+), `color.png` (192x192), `outline.png` (32x32). The manifest must be valid and ready to zip for sideloading. + - **Adding Slack:** Create or update `manifest.yaml` (Slack app manifest) with bot scopes, event subscriptions, and slash commands. Alternatively, configure via api.slack.com app settings. + +20. **Step 5 — Write README.md.** The README is written **last** because it documents the final state of the project. It must contain: + + - **One-paragraph description** of what the example demonstrates. + - **`## Prerequisites`** — Node.js 18+, platform-specific accounts and registrations. + - **`## Environment Setup`** — step-by-step instructions for filling out `.env`. + - **`## Running Locally`** — full launch sequence with tunneling setup. + - **`## Installing the App`** — platform-specific installation instructions (sideloading for Teams, OAuth install for Slack, or both). + - **`## What Was Bridged`** — bullet list mapping original platform concept → target platform equivalent. + - **`## TODO`** — checklist of remaining items. + +## question templates + +Use these as the basis for each `AskUserQuestion` call. Adapt the question text based on what was found in the codebase (e.g., mention the specific file where the feature was detected) and the bridging direction. + +### Y1 — Ephemeral Messages +``` +question: "Your bot uses ephemeral messages ({file}:{line}). How should the target platform handle user-only visibility?" +header: "Y1 Ephemeral" +options: + - label: "refresh.userIds (Recommended)" + description: "Wrap cards with refresh.userIds for per-user content. Covers ~80% of cases. 4-8 hrs." + - label: "Send to 1:1 chat" + description: "Route ephemeral content to user's personal bot chat. Different UX but reliable. 2-4 hrs." + - label: "Build sendEphemeral() helper" + description: "SDK wrapper auto-detecting context. Best if reused across multiple bots. 8-12 hrs." + - label: "{escape hatch}" +``` + +Note: For Teams → Slack, ephemeral is natively supported via `chat.postEphemeral`. This question may be skipped — just use the native API. + +### Y2 — Threaded Replies with reply_broadcast +``` +question: "Your bot uses reply_broadcast ({file}:{line}). How should the target platform handle thread + channel posting?" +header: "Y2 Broadcast" +options: + - label: "Two API calls (Recommended)" + description: "Call reply() and send() separately. Two lines of code, 1-2 hrs." + - label: "Build reply(text, { broadcast }) wrapper" + description: "Convenience method that internally sends both calls. 2-4 hrs." + - label: "{escape hatch}" +``` + +### Y3 — Thread Discovery +``` +question: "Your bot reads thread replies ({file}:{line}). How should the target platform fetch thread history?" +header: "Y3 Threads" +options: + - label: "Graph API direct (Recommended)" + description: "GET /messages/{id}/replies with ChannelMessage.Read.All permission. 4-8 hrs." + - label: "Build getThreadReplies() helper" + description: "Wrapper encapsulating Graph client setup and auth. 8-12 hrs." + - label: "{escape hatch}" +``` + +Note: For Teams → Slack, use `conversations.replies` directly — native API. + +### Y4/5/6 — File Upload +``` +question: "Your bot uploads files ({file}:{line}). How should the target platform handle file operations?" +header: "Y4-6 Files" +options: + - label: "Build sendFile() helper (Recommended)" + description: "Unified wrapper: auto-detects personal/channel, routes to OneDrive/SharePoint, chunks >4MB. 24-40 hrs. The manual flow is a 30-line footgun." + - label: "Manual FileConsentCard flow" + description: "Implement the 3-step consent flow yourself. 16-24 hrs per upload pattern." + - label: "{escape hatch}" +``` + +Note: For Teams → Slack, use `files.uploadV2` directly — much simpler than the Teams consent flow. + +### Y7 — Link Unfurling +``` +question: "Your bot unfurls links ({file}:{line}). How should the target platform handle link previews?" +header: "Y7 Unfurl" +options: + - label: "Cache-first with prefetch (Recommended)" + description: "Cache middleware wraps handler. Without this, the 5-second deadline silently kills slow unfurls. 12-16 hrs." + - label: "Synchronous handler only" + description: "Direct handler, must return within 5 seconds. Only viable for fast data sources. 4-8 hrs." + - label: "{escape hatch}" +``` + +### Y8 — Scheduled Messages +``` +question: "Your bot schedules messages ({file}:{line}). How should the target platform handle deferred delivery?" +header: "Y8 Schedule" +options: + - label: "Functions timer + Cosmos DB (Recommended)" + description: "Store in DB, Azure Functions timer polls and sends via proactive messaging. 16-24 hrs." + - label: "Full scheduler plugin" + description: "Reusable package with scheduleMessage()/cancelScheduledMessage(). 32-48 hrs." + - label: "Power Automate delegation" + description: "Offload to Power Automate flows. Requires license. 8-12 hrs." + - label: "{escape hatch}" +``` + +Note: For Teams → Slack, use `chat.scheduleMessage` directly — native API. + +### Y9 — Reminders +``` +question: "Your bot sets reminders ({file}:{line}). How should the target platform handle reminder delivery?" +header: "Y9 Reminders" +options: + - label: "Piggyback on scheduler (Recommended)" + description: "Reuse Y8 scheduler with setReminder() sending to 1:1 chat. 4-8 hrs if scheduler exists." + - label: "Power Automate + Planner" + description: "Create Planner tasks with due date notifications. 8-12 hrs." + - label: "{escape hatch}" +``` + +Note: For Teams → Slack, use `reminders.add` directly — native API. + +### Y10 — Channel Archive +``` +question: "Your bot archives channels ({file}:{line}). How should the target platform simulate channel archival?" +header: "Y10 Archive" +options: + - label: "Rename + description (Recommended)" + description: "Prefix with [ARCHIVED], update description. Cosmetic but non-destructive. 4-8 hrs." + - label: "Rename + remove members" + description: "Stronger enforcement but destructive — members must be re-invited to undo. 8-12 hrs." + - label: "Team-level archive" + description: "Archive entire Team. Only works if channel is in a dedicated Team. 2-4 hrs." + - label: "{escape hatch}" +``` + +Note: For Teams → Slack, use `conversations.archive` directly — native API. + +### Y11 — Channel Member Removal +``` +question: "Your bot removes channel members ({file}:{line}). How should the target platform handle member removal?" +header: "Y11 Members" +options: + - label: "Two-step Graph API (Recommended)" + description: "List members to resolve membership-id, then delete. Simple and direct. 4-6 hrs." + - label: "Build removeChannelMember() helper" + description: "Wrapper that resolves membership ID internally. Cleaner API. 4-8 hrs." + - label: "{escape hatch}" +``` + +Note: For Teams → Slack, use `conversations.kick` directly — native API. + +### Y12 — Workflow Builder +``` +question: "Your bot uses Workflow Builder ({file}:{line}). How should the target platform handle workflow automation?" +header: "Y12 Workflows" +options: + - label: "Bot-driven orchestration (Recommended)" + description: "Keep logic in the bot. No license dependency, full control. 16-40 hrs." + - label: "Power Automate rebuild" + description: "Rebuild in Power Automate. Custom steps need Premium license. 24-80 hrs." + - label: "Hybrid approach" + description: "Simple flows → Power Automate, complex → bot-driven. Two systems. Varies." + - label: "{escape hatch}" +``` + +### Y13 — Global Shortcuts +``` +question: "Your bot uses global shortcuts ({file}:{line}). How should the target platform expose quick actions?" +header: "Y13 Shortcuts" +options: + - label: "Compose extension (Recommended)" + description: "composeExtensions with commandBox context. Always opens task module. 8-12 hrs." + - label: "Minimal-dismiss pattern" + description: "Task module returns tiny 'Done' card for fire-and-forget actions. 4-8 hrs." + - label: "Bot command replacement" + description: "Replace shortcut with typed command. Simpler but less discoverable. 2-4 hrs." + - label: "{escape hatch}" +``` + +Note: For Teams → Slack, map compose extensions to `app.shortcut` with a global shortcut callback. + +### Y14 — Message Shortcuts +``` +question: "Your bot uses message shortcuts ({file}:{line}). How should the target platform expose message actions?" +header: "Y14 MsgAction" +options: + - label: "Action-based message extension (Recommended)" + description: "composeExtensions with message context. Direct mapping. 4-8 hrs." + - label: "{escape hatch}" +``` + +Note: For Teams → Slack, map action-based message extensions to `app.shortcut` with `message_shortcut` type. + +### Y15 — Dynamic Selects +``` +question: "Your bot uses dynamic select menus ({file}:{line}). How should the target platform handle server-filtered dropdowns?" +header: "Y15 Selects" +options: + - label: "Pre-populated ChoiceSet (Recommended)" + description: "Load all options at dialog open, client-side filtering. Works up to ~500 items. 2-4 hrs." + - label: "Two-step dialog" + description: "Step 1: text search. Step 2: filtered results as ChoiceSet. Works for any size. 8-12 hrs." + - label: "Custom searchable task module" + description: "Embed a web view with search-as-you-type UI. Full control. 16-24 hrs." + - label: "{escape hatch}" +``` + +Note: For Teams → Slack, use `block_suggestion` with `external_data_source` for native dynamic selects. + +### Y16 — App Home +``` +question: "Your bot uses App Home ({file}:{line}). How should the target platform present the bot's home experience?" +header: "Y16 AppHome" +options: + - label: "tab.fetch handler (Recommended)" + description: "Personal tab fires on every open. Closest to AppHomeOpenedEvent. 4-8 hrs." + - label: "install.add welcome only" + description: "Send welcome message once on install. Simple but fires only once. 1-2 hrs." + - label: "Static tab (web content)" + description: "Full web page embedded as personal tab. Richer but needs hosting. 8-16 hrs." + - label: "{escape hatch}" +``` + +Note: For Teams → Slack, map `tab.fetch` to `app_home_opened` event with `views.publish`. + +### Y17 — View Hash +``` +question: "Your bot uses view_hash for race conditions ({file}:{line}). How should the target platform protect against stale updates?" +header: "Y17 ViewHash" +options: + - label: "Manual _version field (Recommended)" + description: "Inject version counter into Action.Submit.data, reject stale. 2-4 hrs." + - label: "Card versioning middleware" + description: "SDK plugin auto-injecting and checking versions. 4-8 hrs." + - label: "{escape hatch}" +``` + +Note: For Teams → Slack, use the native `view_hash` parameter in `views.update` — built-in. + +### Y18 — All Channel Messages +``` +question: "Your bot receives all channel messages without @mention ({file}:{line}). How should the target platform enable this?" +header: "Y18 RSC" +options: + - label: "RSC permission (Recommended)" + description: "Add ChannelMessage.Read.Group to manifest. Config-only, no code change. 1-2 hrs." + - label: "Require @mention" + description: "Change UX to require @mention. Simplifies permissions. 0 hrs." + - label: "{escape hatch}" +``` + +Note: For Teams → Slack, Slack receives all channel messages by default when the bot is in the channel. No special config needed. + +### Y19 — Socket Mode +``` +question: "Your bot uses Socket Mode ({file}:{line}). The target platform requires inbound HTTPS. How do you want to handle transport?" +header: "Y19 Transport" +options: + - label: "Deploy to Azure (Recommended)" + description: "Host in Azure for production. Use Dev Tunnels for local dev. 4-8 hrs." + - label: "Azure Relay" + description: "Hybrid connection for strict on-premises firewalls. Adds latency. 8-16 hrs." + - label: "{escape hatch}" +``` + +Note: For Teams → Slack, Slack supports Socket Mode for firewall-friendly deployments — a simpler story. + +### Y20 — Built-in Retry +``` +question: "Your bot uses Bolt's retryConfig ({file}:{line}). How should the target platform handle retry and resilience?" +header: "Y20 Retry" +options: + - label: "Build RetryPlugin (Recommended)" + description: "Drop-in plugin with exponential backoff, jitter, circuit breaker. Bad retry causes cascading failures. 12-16 hrs." + - label: "Manual retry wrapper" + description: "Hand-roll backoff around outbound calls. Simpler but easy to get wrong. 4-8 hrs." + - label: "{escape hatch}" +``` + +### Y21 — Confirmation Dialogs +``` +question: "Your bot uses confirmation dialogs on buttons ({file}:{line}). How should the target platform confirm destructive actions?" +header: "Y21 Confirm" +options: + - label: "Action.ShowCard inline (Recommended)" + description: "Inline expand with Yes/No buttons. Native Adaptive Card pattern. 2-4 hrs." + - label: "Task module confirm" + description: "Small dialog for confirmation. More prominent. 4-6 hrs." + - label: "Build confirmAction() helper" + description: "Template function generating confirm cards. Reusable. 4-8 hrs." + - label: "{escape hatch}" +``` + +Note: For Teams → Slack, use the native `confirm` object on button elements — built-in. + +### Y22 — App Directory +``` +question: "Your bot is listed in an app directory. How should it be distributed on the target platform?" +header: "Y22 Distrib" +options: + - label: "Org app catalog (Recommended)" + description: "Publish to organization catalog. Requires Teams admin approval. 2-4 hrs." + - label: "Admin sideload" + description: "Upload directly via Teams Admin Center. Quick but no catalog listing. 1-2 hrs." + - label: "Partner Center (public)" + description: "Submit to Teams App Store. 1-2 week review. Requires Partner Network account. 8-16 hrs." + - label: "{escape hatch}" +``` + +Note: For Teams → Slack, submit to the Slack App Directory via api.slack.com. + +### Y23 — Unfurl Domain Wildcards +``` +question: "Your bot uses wildcard domain matching for link unfurling ({file}:{line}). How should the target platform list domains?" +header: "Y23 Wildcards" +options: + - label: "Manual enumeration (Recommended)" + description: "List every subdomain in manifest. Fine for <10 subdomains. 1-2 hrs." + - label: "Manifest generator script" + description: "Script reads subdomains from config and generates manifest array. 4-8 hrs." + - label: "{escape hatch}" +``` + +### Y24 — Multi-Step Modal Stacking +``` +question: "Your bot uses views.push for modal stacking ({file}:{line}). How should the target platform handle multi-step forms?" +header: "Y24 Stacking" +options: + - label: "Flatten into single dialog (Recommended)" + description: "Single dialog with step routing in submit handler. Manageable for 2-3 steps. 8-16 hrs." + - label: "Build StepDialog helper" + description: "Reusable class managing step state, back/forward. Worth it if 3+ wizard flows. 16-24 hrs." + - label: "Separate sequential dialogs" + description: "Close current, open next. No back navigation. Degraded UX. 4-8 hrs." + - label: "{escape hatch}" +``` + +Note: For Teams → Slack, use native `views.push` for stacking — up to 3 levels supported. + +### R1 — True Ephemeral Messages +``` +question: "Your bot relies on true ephemeral messages — a Teams platform gap. Teams has no visibility:'user' flag. How do you want to handle this?" +header: "R1 Ephemeral" +options: + - label: "Accept & Redesign (Recommended)" + description: "refresh.userIds for cards, 1:1 chat for text. Different but functional." + - label: "Defer" + description: "Drop ephemeral behavior entirely. Show messages to everyone." + - label: "{escape hatch}" +``` + +Note: For Teams → Slack, this is a non-issue — Slack has native ephemeral support. + +### R2 — Custom Emoji Reactions +``` +question: "Your bot uses emoji reactions as workflow signals — Teams only has 6 fixed reactions. How do you want to handle this?" +header: "R2 Reactions" +options: + - label: "Accept & Redesign (Recommended)" + description: "Replace reaction workflows with Action.Submit card buttons. Better for audit trails." + - label: "Map to 6 fixed reactions" + description: "Map your most important reactions to like/heart/laugh/surprised/sad/angry. Lossy." + - label: "{escape hatch}" +``` + +Note: For Teams → Slack, Slack supports unlimited custom emoji reactions — direct mapping. + +### R3 — viewClosed / Cancel Notification +``` +question: "Your bot uses viewClosed callbacks — Teams sends no notification on dialog dismiss. How do you want to handle this?" +header: "R3 Cancel" +options: + - label: "Build Custom (Recommended)" + description: "Timeout-based cleanup (5-min TTL) + explicit Cancel button inside the dialog." + - label: "Defer" + description: "Drop cancel cleanup entirely. Accept potential stale locks." + - label: "{escape hatch}" +``` + +Note: For Teams → Slack, use `notify_on_close: true` in `views.open` — native support. + +### R4 — Mid-Form Dynamic Updates +``` +question: "Your bot uses blockAction inside modals for dynamic form updates — a Teams platform gap. How do you want to handle this?" +header: "R4 Dynamic" +options: + - label: "Accept & Redesign (Recommended)" + description: "Multi-step dialogs for dependent fields. Action.ToggleVisibility for simple show/hide." + - label: "Build custom web-based task module" + description: "Embed a full web form in the task module for complete control. Much more effort." + - label: "{escape hatch}" +``` + +Note: For Teams → Slack, use `block_actions` inside modals with `views.update` — native support. + +### R5 — Server-Side Field Validation +``` +question: "Your bot uses ackWithErrors for inline field validation — a Teams platform gap. How do you want to handle this?" +header: "R5 Validate" +options: + - label: "Build Custom (Recommended)" + description: "Re-open dialog with pre-populated data + error messages in field labels." + - label: "Client-side only" + description: "Use isRequired/regex/maxLength. Covers simple cases only." + - label: "{escape hatch}" +``` + +Note: For Teams → Slack, use `response_action: errors` in `view_submission` handler — native support. + +### R6 — Dialog Stacking +``` +question: "Your bot uses views.push for dialog stacking — a Teams platform gap. How do you want to handle this?" +header: "R6 Stacking" +options: + - label: "Accept & Redesign (Recommended)" + description: "Single dialog with step routing. Same approach as Y24. Simulate Back with a button." + - label: "Build custom web-based task module" + description: "Embed a web app with real navigation in the task module. Full control. High effort." + - label: "{escape hatch}" +``` + +Note: For Teams → Slack, use native `views.push` — up to 3 levels. + +### R7 — Scheduled Message API +``` +question: "Your bot depends on chat.scheduleMessage — a Teams platform gap. Teams has no server-side scheduling. How do you want to handle this?" +header: "R7 ScheduleAPI" +options: + - label: "Build Custom (Recommended)" + description: "Self-managed scheduler from Y8 (Cosmos DB + Functions timer). Works, just boilerplate." + - label: "Defer" + description: "Drop scheduling entirely. Users trigger messages manually." + - label: "{escape hatch}" +``` + +Note: For Teams → Slack, use native `chat.scheduleMessage` — direct mapping. + +### R8 — Channel Archive +``` +question: "Your bot archives individual channels — Teams can only archive entire Teams. How do you want to handle this?" +header: "R8 Archive" +options: + - label: "Accept & Redesign (Recommended)" + description: "Rename with [ARCHIVED] prefix. Good enough for 90% of cases." + - label: "Rename + remove all members" + description: "Stronger enforcement but destructive. Hard to undo." + - label: "{escape hatch}" +``` + +Note: For Teams → Slack, use native `conversations.archive` — direct mapping. + +### R9 — Retroactive Link Unfurling +``` +question: "Your bot benefits from retroactive link unfurling — Teams only unfurls links in new messages. How do you want to handle this?" +header: "R9 Retroactive" +options: + - label: "Defer (Recommended)" + description: "No workaround exists. Don't waste time. New messages unfurl fine." + - label: "Build manual preview command" + description: "Bot command where users paste a URL to get a preview card. Niche." + - label: "{escape hatch}" +``` + +### R10 — Firewall-Friendly Transport +``` +question: "Your bot relies on Socket Mode for firewall-friendly transport — Teams requires inbound HTTPS. How do you want to handle this?" +header: "R10 Firewall" +options: + - label: "Accept & Redesign (Recommended)" + description: "Deploy to Azure (it's 2026). Dev Tunnels for local dev." + - label: "Azure Relay" + description: "Hybrid connection for strict on-premises requirements. Adds latency." + - label: "{escape hatch}" +``` + +Note: For Teams → Slack, Slack's Socket Mode provides firewall-friendly transport natively. + +## defaults table + +When the developer picks "You Decide Everything" or "You Decide Everything Else", apply these defaults for all remaining decisions: + +| Feature | Default Option | Strategy | +|---|---|---| +| Y1 | A | `refresh.userIds` (Slack→Teams) / `chat.postEphemeral` (Teams→Slack) | +| Y2 | A | Two API calls (Slack→Teams) / `reply_broadcast` (Teams→Slack) | +| Y3 | A | Graph API direct (Slack→Teams) / `conversations.replies` (Teams→Slack) | +| Y4/5/6 | B | `sendFile()` helper (Slack→Teams) / `files.uploadV2` (Teams→Slack) | +| Y7 | B | Cache-first with prefetch (Slack→Teams) / `link_shared` + `chat.unfurl` (Teams→Slack) | +| Y8 | A | Functions timer + Cosmos DB (Slack→Teams) / `chat.scheduleMessage` (Teams→Slack) | +| Y9 | A | Piggyback on Y8 scheduler (Slack→Teams) / `reminders.add` (Teams→Slack) | +| Y10 | A | Rename + description (Slack→Teams) / `conversations.archive` (Teams→Slack) | +| Y11 | A | Two-step Graph API (Slack→Teams) / `conversations.kick` (Teams→Slack) | +| Y12 | B | Bot-driven orchestration | +| Y13 | A | Compose extension (Slack→Teams) / `app.shortcut` (Teams→Slack) | +| Y14 | A | Action-based message extension (Slack→Teams) / `message_shortcut` (Teams→Slack) | +| Y15 | A | Pre-populated ChoiceSet (Slack→Teams) / `block_suggestion` (Teams→Slack) | +| Y16 | B | `tab.fetch` handler (Slack→Teams) / `views.publish` (Teams→Slack) | +| Y17 | A | Manual `_version` field (Slack→Teams) / `view_hash` (Teams→Slack) | +| Y18 | A | RSC permission (Slack→Teams) / Default in Slack (Teams→Slack) | +| Y19 | B | Deploy to Azure (Slack→Teams) / Socket Mode (Teams→Slack) | +| Y20 | B | `RetryPlugin` (Slack→Teams) / Bolt `retryConfig` (Teams→Slack) | +| Y21 | A | `Action.ShowCard` inline (Slack→Teams) / `confirm` object (Teams→Slack) | +| Y22 | B | Org app catalog (Slack→Teams) / Slack App Directory (Teams→Slack) | +| Y23 | A | Manual enumeration (Slack→Teams) / Wildcard support (Teams→Slack) | +| Y24 | A | Flatten into single dialog (Slack→Teams) / `views.push` (Teams→Slack) | +| R1 | — | Accept & Redesign (Slack→Teams) / Native (Teams→Slack) | +| R2 | — | Accept & Redesign (Slack→Teams) / Native (Teams→Slack) | +| R3 | — | Build Custom (Slack→Teams) / `notify_on_close` (Teams→Slack) | +| R4 | — | Accept & Redesign (Slack→Teams) / `block_actions` + `views.update` (Teams→Slack) | +| R5 | — | Build Custom (Slack→Teams) / `response_action: errors` (Teams→Slack) | +| R6 | — | Accept & Redesign (Slack→Teams) / `views.push` (Teams→Slack) | +| R7 | — | Build Custom (Slack→Teams) / `chat.scheduleMessage` (Teams→Slack) | +| R8 | — | Accept & Redesign (Slack→Teams) / `conversations.archive` (Teams→Slack) | +| R9 | — | Defer | +| R10 | — | Accept & Redesign (Slack→Teams) / Socket Mode (Teams→Slack) | + +## instructions + +Pair with: +- `MigrationDecisionMatrix.md` — source of truth for all decision options, effort estimates, and profile definitions +- All 22 bridge experts in `.experts/bridge/` — referenced in the Phase 3 output for implementation details +- `SlackToTeamsMigrationAnalysis.md` — cross-reference for feature status (G/Y/R) + +Do a web search for: +- "Microsoft Teams Bot Framework SDK TypeScript latest changes 2026" +- "Slack Bolt SDK TypeScript latest changes 2026" + +## research + +Deep Research prompt: + +"Write an interactive cross-platform bridging advisor for Slack↔Teams bot development. Cover codebase analysis (detecting both Slack and Teams API patterns), direction detection (which platform exists, which to add), bot profile classification (A-D by complexity), and per-feature decision walkthrough for 24 YELLOW and 10 RED platform gaps — with bidirectional defaults for each direction. Include question templates with effort estimates and a defaults table for one-click acceptance." diff --git a/plugins/microsoft-365-agents-toolkit/skills/teams-app-developer/experts/bridge/cross-platform-architecture-ts.md b/plugins/microsoft-365-agents-toolkit/skills/teams-app-developer/experts/bridge/cross-platform-architecture-ts.md new file mode 100644 index 0000000..5273576 --- /dev/null +++ b/plugins/microsoft-365-agents-toolkit/skills/teams-app-developer/experts/bridge/cross-platform-architecture-ts.md @@ -0,0 +1,165 @@ +# cross-platform-architecture-ts + +## purpose + +Architecture patterns for hosting both a Slack bot (Bolt.js) and a Teams bot (Bot Framework / Teams SDK) in a single TypeScript server — shared Express instance, separate receiver pipelines, shared business logic layer, and deployment considerations. + +## rules + +1. **Use a single Express server as the HTTP foundation.** Both Slack (HTTP receiver) and Teams (webhook POST) can share one Express app on one port. Mount Slack routes at `/slack/events` and Teams routes at `/api/messages`. +2. **Keep bot SDKs in separate modules.** Initialize Bolt's `ExpressReceiver` and Teams' `CloudAdapter` independently. Neither should know about the other. Share only the business logic layer. +3. **Extract business logic into a platform-agnostic service layer.** Functions like `processUserMessage(text, userId, context)` should return platform-neutral results (text, structured data). Platform adapters convert to Block Kit or Adaptive Cards. +4. **Use Bolt's `ExpressReceiver` (not the default `HTTPReceiver`) for shared Express.** Create the Express app yourself, pass it to `ExpressReceiver` via the `app` option, and also mount Teams routes on the same instance. +5. **For Socket Mode Slack + HTTP Teams, run both receivers.** Start `SocketModeReceiver` for Slack (WebSocket, no HTTP needed) and Express for Teams webhook. This is simpler than sharing Express — Slack doesn't need an HTTP endpoint at all. +6. **Normalize user identity across platforms.** Map Slack user IDs (`U...`) and Teams AAD object IDs to a common identity. Store mappings in a shared database keyed by email or external ID. +7. **Normalize conversation context.** Create a `ConversationContext` type with `platform: "slack" | "teams"`, `channelId`, `threadId`, `userId`, and `replyFn`. Each platform adapter populates this from its native event. +8. **Handle media differences in the adapter layer.** Slack uses Block Kit (`mrkdwn`, `blocks[]`). Teams uses Adaptive Cards (JSON schema, `AdaptiveCard`). The service layer should return structured data that each adapter renders into the platform's format. +9. **Share environment config but separate credentials.** Use a single `.env` or config file with prefixed keys: `SLACK_BOT_TOKEN`, `SLACK_APP_TOKEN`, `TEAMS_APP_ID`, `TEAMS_APP_PASSWORD`, `TEAMS_TENANT_ID`. +10. **Deploy as a single container or serverless function.** Both bots run in the same Node.js process. Use health checks for both: Slack via Socket Mode ping/pong, Teams via a health probe endpoint. + +## patterns + +### Shared Express with ExpressReceiver (Slack HTTP) + Teams webhook + +```typescript +import express from "express"; +import { App, ExpressReceiver } from "@slack/bolt"; +import { CloudAdapter, ConfigurationServiceClientCredentialFactory, createBotFrameworkAuthenticationFromConfiguration } from "botbuilder"; + +// 1. Create shared Express app +const expressApp = express(); + +// 2. Initialize Slack with ExpressReceiver +const slackReceiver = new ExpressReceiver({ + signingSecret: process.env.SLACK_SIGNING_SECRET!, + app: expressApp, // share the Express instance + endpoints: "/slack/events", // Slack events endpoint +}); + +const slackApp = new App({ + token: process.env.SLACK_BOT_TOKEN!, + receiver: slackReceiver, +}); + +// 3. Initialize Teams on the same Express app +const credFactory = new ConfigurationServiceClientCredentialFactory({ + MicrosoftAppId: process.env.TEAMS_APP_ID!, + MicrosoftAppPassword: process.env.TEAMS_APP_PASSWORD!, + MicrosoftAppTenantId: process.env.TEAMS_TENANT_ID!, +}); +const auth = createBotFrameworkAuthenticationFromConfiguration(null, credFactory); +const adapter = new CloudAdapter(auth); + +expressApp.post("/api/messages", async (req, res) => { + await adapter.process(req, res, (context) => teamsBot.run(context)); +}); + +// 4. Health check +expressApp.get("/health", (_req, res) => res.json({ slack: "ok", teams: "ok" })); + +// 5. Start +expressApp.listen(3000, () => console.log("Dual bot running on :3000")); +``` + +### Socket Mode Slack + HTTP Teams (simpler) + +```typescript +import { App } from "@slack/bolt"; +import express from "express"; + +// Slack: Socket Mode (no HTTP needed) +const slackApp = new App({ + token: process.env.SLACK_BOT_TOKEN!, + appToken: process.env.SLACK_APP_TOKEN!, + socketMode: true, +}); + +// Teams: Express webhook +const expressApp = express(); +// ... mount Teams adapter on expressApp ... + +await slackApp.start(); // WebSocket +expressApp.listen(3978, () => {}); // HTTP for Teams +``` + +### Platform-agnostic service layer + +```typescript +// service/message-handler.ts — no platform imports +export interface BotResponse { + text: string; + structured?: { + title: string; + body: string; + actions?: { label: string; id: string }[]; + }; +} + +export async function handleUserMessage( + text: string, + userId: string, + platform: "slack" | "teams" +): Promise { + // Business logic, AI calls, database queries — platform-agnostic + return { + text: `You said: ${text}`, + structured: { title: "Echo", body: text }, + }; +} + +// adapters/slack-adapter.ts +import { handleUserMessage } from "../service/message-handler.js"; + +slackApp.message(/.*/, async ({ message, say }) => { + const response = await handleUserMessage( + (message as any).text ?? "", + (message as any).user ?? "", + "slack" + ); + await say(response.text); // or convert response.structured to Block Kit +}); + +// adapters/teams-adapter.ts +import { handleUserMessage } from "../service/message-handler.js"; + +class TeamsBot extends ActivityHandler { + constructor() { + super(); + this.onMessage(async (context, next) => { + const response = await handleUserMessage( + context.activity.text ?? "", + context.activity.from?.id ?? "", + "teams" + ); + await context.sendActivity(response.text); // or convert to Adaptive Card + await next(); + }); + } +} +``` + +## pitfalls + +- **Express body parsing conflicts.** Slack needs raw body parsing for signature verification. Teams needs `express.json()`. Order middleware carefully — apply `express.json()` only to Teams routes, and let `ExpressReceiver` handle Slack routes' body parsing. +- **Port conflicts in development.** If Slack's `ExpressReceiver` and your Teams server both try to listen on the same port, one will fail. Share a single `listen()` call, or use Socket Mode for Slack. +- **Credential leakage between adapters.** Keep Slack and Teams clients in separate modules. A bug that passes the Slack token to a Teams API call (or vice versa) is hard to debug and a security risk. +- **Adaptive Cards and Block Kit are not interchangeable.** Don't try to build a "universal card format" — the data models are fundamentally different. Keep a thin adapter that transforms structured data to each format. +- **Tunneling for local development.** You need two tunnel endpoints (one for Slack, one for Teams) or route both through the same tunnel with path-based routing. ngrok or Cloudflare Tunnel work for both. + +## references + +- Bolt.js `ExpressReceiver`: https://slack.dev/bolt-js/concepts/custom-routes +- Bot Framework `CloudAdapter`: https://learn.microsoft.com/en-us/javascript/api/botbuilder/cloudadapter +- Express 5: https://expressjs.com/en/5x/api.html + +## instructions + +Use this expert when designing a server that hosts both Slack and Teams bots, or when deciding on a deployment architecture for multi-platform bot support. This is the foundational architecture expert for the slack-plus-teams project's core use case. + +Pair with: `runtime.bolt-foundations-ts.md` (Slack setup), `../teams/runtime.app-init-ts.md` (Teams setup), `identity-oauth-bridge-ts.md` (cross-platform identity), `ui-block-kit-adaptive-cards-ts.md` (UI adapter patterns). + +## research + +Deep Research prompt: + +"Document architecture patterns for hosting both a Slack Bolt.js bot and a Microsoft Teams Bot Framework bot in a single Node.js/TypeScript server. Cover: shared Express server with route separation, ExpressReceiver for Slack HTTP mode, Socket Mode for Slack + separate HTTP for Teams, CloudAdapter integration on shared Express, platform-agnostic service layer design, Block Kit vs Adaptive Card adapter pattern, identity normalization across platforms, credential separation, environment configuration, health monitoring for both platforms, deployment as single container, and body parsing middleware ordering for signature verification compatibility." diff --git a/plugins/microsoft-365-agents-toolkit/skills/teams-app-developer/experts/bridge/events-activities-ts.md b/plugins/microsoft-365-agents-toolkit/skills/teams-app-developer/experts/bridge/events-activities-ts.md new file mode 100644 index 0000000..ddd3ea7 --- /dev/null +++ b/plugins/microsoft-365-agents-toolkit/skills/teams-app-developer/experts/bridge/events-activities-ts.md @@ -0,0 +1,413 @@ +# events-activities-ts + +## purpose + +Bridges Slack event subscriptions and Teams activity handlers for cross-platform bots targeting Slack, Teams, or both. + +## rules + +1. Slack's `app.event('event_name')` maps to Teams' `app.on('route_name')` pattern. The event names and payload shapes are completely different between the two platforms. Always consult the mapping table below for the correct Teams route. [github.com/microsoft/teams.ts](https://github.com/microsoft/teams.ts) +2. Slack's `app.message(pattern)` maps directly to Teams' `app.message(pattern)` for pattern-matched messages. For a catch-all, Slack uses `app.message(async ...)` while Teams uses `app.on('message', async ...)`. [github.com/microsoft/teams.ts](https://github.com/microsoft/teams.ts) +3. Slack's `app.event('app_mention')` has no dedicated Teams route. In Teams channels, bots receive messages only when @mentioned, so the standard `app.on('message')` handler already implies a mention context. Check `activity.entities` for mention details or use the `mention` route if available. [learn.microsoft.com -- Mentions in bots](https://learn.microsoft.com/en-us/microsoftteams/platform/bots/how-to/conversations/channel-and-group-conversations#receive-only-at-mentioned-messages) +4. Slack's `app.event('member_joined_channel')` and `app.event('member_left_channel')` map to Teams' `app.on('conversationUpdate')` with inspection of `activity.membersAdded` or `activity.membersRemoved` arrays. [learn.microsoft.com -- conversationUpdate](https://learn.microsoft.com/en-us/microsoftteams/platform/bots/how-to/conversations/subscribe-to-conversation-events) +5. Slack's `say()` maps to Teams' `send()` for posting a new message. Slack's threaded replies via `say({ thread_ts })` map to Teams' `reply()` method which uses `replyToId` internally for threaded conversation. [github.com/microsoft/teams.ts](https://github.com/microsoft/teams.ts) +6. Slack's `app.event('reaction_added')` and `app.event('reaction_removed')` map to Teams' `app.on('messageReaction')` route. Teams delivers both added and removed reactions in a single route -- inspect `activity.reactionsAdded` and `activity.reactionsRemoved` arrays. [learn.microsoft.com -- Message reactions](https://learn.microsoft.com/en-us/microsoftteams/platform/bots/how-to/conversations/subscribe-to-conversation-events#message-reaction-events) +7. Slack's ephemeral messages (`respond({ response_type: 'ephemeral' })`) have **no Teams equivalent**. Redesign ephemeral responses as: (a) messages in personal (1:1) chat, (b) Adaptive Cards with user-specific `Action.Execute` refresh, or (c) simply visible messages if privacy is not critical. [learn.microsoft.com -- Conversations](https://learn.microsoft.com/en-us/microsoftteams/platform/bots/how-to/conversations/conversation-basics) +8. In Teams channels, bots require @mention to receive messages (default behavior). This is fundamentally different from Slack where bots receive all channel messages. To receive all messages without mention, the app must request Resource-Specific Consent (RSC) permission `ChannelMessage.Read.Group`. [learn.microsoft.com -- RSC](https://learn.microsoft.com/en-us/microsoftteams/platform/graph-api/rsc/resource-specific-consent) +9. Slack's `app.event('app_home_opened')` (App Home tab) maps to Teams' static tab or `tab.open` invoke route for personal tabs. There is no direct equivalent -- Teams tabs are web pages rendered in an iframe, not bot-driven views. [learn.microsoft.com -- Personal tabs](https://learn.microsoft.com/en-us/microsoftteams/platform/tabs/what-are-tabs) +10. Teams provides install/uninstall events via `app.on('install.add')` and `app.on('install.remove')` which have no direct Slack equivalent. Use these to send welcome messages and store conversation references for proactive messaging. [github.com/microsoft/teams.ts](https://github.com/microsoft/teams.ts) +11. **Teams has only 6 reaction types** (`like`, `heart`, `laugh`, `surprised`, `sad`, `angry`) — Slack supports unlimited custom emoji reactions. Bots that use reactions as workflow triggers (e.g., `:white_check_mark:` to mark approved, `:eyes:` to claim a ticket) must be redesigned. Replace reaction-based workflows with `Action.Submit` buttons on Adaptive Cards, which provide explicit, typed actions instead of ambiguous emoji semantics. [learn.microsoft.com -- Message reactions](https://learn.microsoft.com/en-us/microsoftteams/platform/bots/how-to/conversations/subscribe-to-conversation-events#message-reaction-events) +12. **Threading model differs significantly.** Slack uses `thread_ts` to identify a parent message and `reply_broadcast` to also post a thread reply to the channel. Teams uses `replyToId` in the activity and the `reply()` method. There is **no "also send to channel"** equivalent in Teams — a reply stays in the thread. Thread discovery requires the Graph API: `GET /teams/{team-id}/channels/{channel-id}/messages/{message-id}/replies`. This Graph call requires `ChannelMessage.Read.All` application permission. [learn.microsoft.com -- List replies](https://learn.microsoft.com/en-us/graph/api/chatmessage-list-replies) + +## patterns + +### Migrating message handlers (say to send, thread_ts to reply) + +**Slack (before):** + +```typescript +import { App } from "@slack/bolt"; + +const app = new App({ + token: process.env.SLACK_BOT_TOKEN!, + signingSecret: process.env.SLACK_SIGNING_SECRET!, +}); + +// Pattern-matched message +app.message(/^hello$/i, async ({ message, say }) => { + await say(`Hello <@${(message as any).user}>!`); +}); + +// Catch-all message handler +app.message(async ({ message, say }) => { + if (message.subtype) return; + // Reply in thread + await say({ + text: `You said: ${(message as any).text}`, + thread_ts: (message as any).ts, + }); +}); + +// App mention event +app.event("app_mention", async ({ event, say }) => { + await say(`Thanks for mentioning me, <@${event.user}>!`); +}); +``` + +**Teams (after):** + +```typescript +import { App } from "@microsoft/teams.apps"; +import { ConsoleLogger } from "@microsoft/teams.common"; +import { DevtoolsPlugin } from "@microsoft/teams.dev"; + +const app = new App({ + logger: new ConsoleLogger("my-bot", { level: "info" }), + plugins: [new DevtoolsPlugin()], +}); + +// Pattern-matched message (same API shape as Slack) +app.message(/^hello$/i, async ({ send, activity }) => { + await send(`Hello ${activity.from.name}!`); +}); + +// Catch-all message handler +app.on("message", async ({ activity, reply }) => { + // reply() creates a threaded reply (like say({ thread_ts }) in Slack) + await reply(`You said: "${activity.text}"`); +}); + +// No separate app_mention route needed -- in channels, bots only +// receive messages when @mentioned, so app.on('message') covers it. +// For explicit mention detection: +app.on("message", async ({ activity, send }) => { + const mentions = activity.entities?.filter( + (e: any) => e.type === "mention" && e.mentioned?.id !== activity.recipient?.id + ); + if (mentions?.length) { + await send("I see you mentioned someone!"); + } +}); + +app.start(3978); +``` + +### Migrating member join/leave and reaction events + +**Slack (before):** + +```typescript +import { App } from "@slack/bolt"; + +const app = new App({ + token: process.env.SLACK_BOT_TOKEN!, + signingSecret: process.env.SLACK_SIGNING_SECRET!, +}); + +// Member joined channel +app.event("member_joined_channel", async ({ event, say }) => { + await say(`Welcome to the channel, <@${event.user}>!`); +}); + +// Member left channel +app.event("member_left_channel", async ({ event, client }) => { + await client.chat.postMessage({ + channel: event.channel, + text: `<@${event.user}> has left the channel.`, + }); +}); + +// Reaction added +app.event("reaction_added", async ({ event, client }) => { + if (event.reaction === "eyes") { + await client.chat.postMessage({ + channel: event.item.channel, + text: `Someone is looking at this! :eyes:`, + thread_ts: event.item.ts, + }); + } +}); +``` + +**Teams (after):** + +```typescript +import { App } from "@microsoft/teams.apps"; +import { ConsoleLogger } from "@microsoft/teams.common"; +import { DevtoolsPlugin } from "@microsoft/teams.dev"; + +const app = new App({ + logger: new ConsoleLogger("my-bot", { level: "info" }), + plugins: [new DevtoolsPlugin()], +}); + +// Member joined -- conversationUpdate with membersAdded +app.on("conversationUpdate", async ({ activity, send }) => { + if (activity.membersAdded?.length) { + for (const member of activity.membersAdded) { + // Skip the bot itself + if (member.id !== activity.recipient?.id) { + await send(`Welcome to the channel, ${member.name}!`); + } + } + } + + // Member left -- conversationUpdate with membersRemoved + if (activity.membersRemoved?.length) { + for (const member of activity.membersRemoved) { + if (member.id !== activity.recipient?.id) { + await send(`${member.name} has left the channel.`); + } + } + } +}); + +// Reaction events -- messageReaction route +app.on("messageReaction" as any, async ({ activity, send }) => { + if (activity.reactionsAdded?.length) { + for (const reaction of activity.reactionsAdded) { + if (reaction.type === "like") { + await send("Someone liked a message!"); + } + } + } +}); + +// Install event (no Slack equivalent) -- good for welcome messages +app.on("install.add", async ({ send, activity }) => { + await send("Thanks for installing me! Type 'help' to get started."); +}); + +app.start(3978); +``` + +### Event mapping reference table + +| Slack Event / Handler | Teams Route / Handler | Notes | +|---|---|---| +| `app.message(pattern)` | `app.message(pattern)` | Direct equivalent; Teams uses RegExp | +| `app.message(async ...)` (catch-all) | `app.on('message', async ...)` | Named route for catch-all | +| `app.event('app_mention')` | `app.on('message')` | Channel messages imply @mention | +| `app.event('member_joined_channel')` | `app.on('conversationUpdate')` + `membersAdded` | Check `activity.membersAdded` array | +| `app.event('member_left_channel')` | `app.on('conversationUpdate')` + `membersRemoved` | Check `activity.membersRemoved` array | +| `app.event('reaction_added')` | `app.on('messageReaction')` + `reactionsAdded` | Inspect `activity.reactionsAdded` | +| `app.event('reaction_removed')` | `app.on('messageReaction')` + `reactionsRemoved` | Inspect `activity.reactionsRemoved` | +| `app.event('message_changed')` | `app.on('messageUpdate')` | Message edit event | +| `app.event('message_deleted')` | `app.on('messageDelete')` | Message deletion event | +| `app.event('app_home_opened')` | `app.on('tab.open')` or static tab | Web-based tab, not bot view | +| `app.event('team_join')` | `app.on('conversationUpdate')` + `membersAdded` | Same route as channel join | +| `say(text)` | `send(text)` | Post new message | +| `say({ thread_ts })` | `reply(text)` | Threaded reply | +| `say({ thread_ts, reply_broadcast: true })` | `reply(text)` + `send(text)` | No single-call equivalent; must send twice | +| `respond({ response_type: 'ephemeral' })` | *(no equivalent)* | Redesign required | +| Reaction: any custom emoji (`:white_check_mark:`, `:rocket:`, etc.) | Reaction: 6 fixed types only (`like`, `heart`, `laugh`, `surprised`, `sad`, `angry`) | Custom emoji reactions impossible | +| Thread discovery: `conversations.replies(channel, thread_ts)` | Graph API `GET /messages/{id}/replies` | Requires `ChannelMessage.Read.All` permission | +| *(no equivalent)* | `app.on('install.add')` | Bot installed event | +| *(no equivalent)* | `app.on('install.remove')` | Bot uninstalled event | +| *(no equivalent)* | `app.on('typing')` | User typing indicator | + +### Reaction workflow workaround: Adaptive Card buttons (R2) + +Replace Slack's custom emoji reaction workflows with explicit `Action.Submit` buttons on Adaptive Cards — the recommended Teams alternative. + +```typescript +// Slack (before): reaction-based approval +app.event("reaction_added", async ({ event, client }) => { + if (event.reaction === "white_check_mark") { + await client.chat.postMessage({ + channel: event.item.channel, + text: `Approved by <@${event.user}>`, + thread_ts: event.item.ts, + }); + } +}); + +// Teams (after): button-based approval +app.on("card.action" as any, async ({ activity }) => { + const data = activity.value?.action?.data ?? activity.value; + if (data?.action === "approve") { + return { + status: 200, + body: { + type: "AdaptiveCard", version: "1.5", + body: [{ + type: "TextBlock", + text: `Approved by ${activity.from?.name}`, + color: "Good", weight: "Bolder", + }], + // No actions = card becomes read-only + }, + }; + } +}); + +// Send the approval card (replaces posting a message users react to) +function buildApprovalCard(requestId: string): object { + return { + type: "AdaptiveCard", version: "1.5", + body: [ + { type: "TextBlock", text: `Request #${requestId} needs approval`, weight: "Bolder" }, + ], + actions: [ + { type: "Action.Submit", title: "Approve", style: "positive", + data: { action: "approve", requestId } }, + { type: "Action.Submit", title: "Reject", style: "destructive", + data: { action: "reject", requestId } }, + ], + }; +} +``` + +**Why buttons are better:** Buttons provide explicit typed actions with an audit trail. Reactions are ambiguous (`:thumbsup:` vs `:+1:` vs `:white_check_mark:`) and produce no structured data. + +**Reverse (Teams → Slack):** Slack supports unlimited custom emoji — map directly or keep the button pattern (works on both platforms). + +### Thread broadcast helper (Y2) + +Slack's `reply_broadcast: true` sends a thread reply that also appears in the channel. Teams has no single-call equivalent — use a helper that makes both calls. + +```typescript +// Teams: replicate reply_broadcast behavior +async function replyWithBroadcast( + ctx: { reply: (text: string) => Promise; send: (text: string) => Promise }, + text: string +): Promise { + await ctx.reply(text); // threaded reply + await ctx.send(text); // also post to channel +} + +// Usage in a handler +app.on("message", async (ctx) => { + if (ctx.activity.text?.includes("broadcast")) { + await replyWithBroadcast(ctx, "This appears in both the thread and the channel."); + } +}); +``` + +**Don't:** Try to batch into a single API call — Teams doesn't support it. Two calls is the correct pattern. + +**Reverse (Teams → Slack):** Use `say({ text, thread_ts: message.ts, reply_broadcast: true })` natively — single call. + +### Thread discovery via Graph API (Y3) + +Fetching thread replies in Teams requires the Graph API, unlike Slack's simple `conversations.replies()`. + +```typescript +import { Client } from "@microsoft/microsoft-graph-client"; + +async function getThreadReplies( + graphClient: Client, + teamId: string, + channelId: string, + messageId: string, + top: number = 50 +): Promise { + const response = await graphClient + .api(`/teams/${teamId}/channels/${channelId}/messages/${messageId}/replies`) + .top(top) + .get(); + return response.value; +} + +// Usage in a handler (requires ChannelMessage.Read.All application permission) +app.on("message", async ({ activity, send }) => { + if (activity.text?.match(/^\/?replies/i)) { + const replies = await getThreadReplies( + graphClient, + activity.channelData?.teamsTeamId, + activity.channelData?.teamsChannelId, + activity.conversation?.id?.split(";")[0] ?? "" + ); + await send(`Found ${replies.length} replies in this thread.`); + } +}); +``` + +**Watch out for:** `ChannelMessage.Read.All` is an application permission requiring admin consent. If you only need replies in the bot's own conversations, delegated permissions may suffice. + +**Reverse (Teams → Slack):** Use `conversations.replies({ channel, ts: thread_ts })` natively — no special permissions needed. + +### RSC permission for all channel messages (Y16) + +Add RSC permission to the Teams manifest so the bot receives all channel messages without @mention — matching Slack's default behavior. + +```json +{ + "webApplicationInfo": { + "id": "{{CLIENT_ID}}", + "resource": "api://{{CLIENT_ID}}" + }, + "authorization": { + "permissions": { + "resourceSpecific": [ + { "name": "ChannelMessage.Read.Group", "type": "Application" } + ] + } + } +} +``` + +Also strip @mention text from messages that do include a mention: + +```typescript +const app = new App({ + // ... other options + activity: { mentions: { stripText: true } }, +}); +``` + +**Don't:** Change your UX to require @mention unless your bot genuinely shouldn't listen to all messages. + +**Reverse (Teams → Slack):** Slack bots receive all messages in channels they're added to by default — no config needed. + +### Reverse direction (Teams → Slack) + +For Teams → Slack, reverse the mapping -- Teams routes map back to Slack events: +- `app.on('message')` → `app.message(async ...)` catch-all or `app.event('app_mention')` if handling @mentions specifically +- `app.message(pattern)` → `app.message(pattern)` (direct equivalent) +- `app.on('conversationUpdate')` + `membersAdded` → `app.event('member_joined_channel')` +- `app.on('conversationUpdate')` + `membersRemoved` → `app.event('member_left_channel')` +- `app.on('messageReaction')` + `reactionsAdded` → `app.event('reaction_added')` -- note Teams has 6 fixed types; Slack supports unlimited custom emoji +- `app.on('messageReaction')` + `reactionsRemoved` → `app.event('reaction_removed')` +- `app.on('messageUpdate')` → `app.event('message_changed')` +- `app.on('messageDelete')` → `app.event('message_deleted')` +- `app.on('install.add')` → no direct Slack equivalent (use `app_home_opened` or OAuth completion callback for welcome messages) +- `send(text)` → `say(text)` +- `reply(text)` → `say({ text, thread_ts: message.ts })` +- Add `ack()` calls to Slack event handlers where required +- Slack bots receive all channel messages by default (no @mention required) -- adjust UX expectations accordingly + +## pitfalls + +- **Assuming all channel messages are delivered**: In Teams channels, bots only receive messages when @mentioned. This is the biggest behavioral difference from Slack. Design accordingly or use RSC permissions for broader message access. +- **Missing ephemeral message redesign**: Code that uses `respond({ response_type: 'ephemeral' })` will not work in Teams. Identify all ephemeral patterns early and plan alternative UX (personal chat, card refresh, or visible messages). +- **Not filtering the bot from `membersAdded`**: The `conversationUpdate` event fires when the bot itself is added. Always check `member.id !== activity.recipient?.id` to avoid the bot welcoming itself. +- **Thread model differences**: Slack threads use `thread_ts` on individual messages. Teams threaded replies use `reply()` or `replyToId`. The nesting model is similar but the API is different. +- **Reaction type mismatch**: Slack reactions use emoji names (e.g., `"eyes"`, `"thumbsup"`). Teams reactions use a limited set of types (`"like"`, `"heart"`, `"laugh"`, `"surprised"`, `"sad"`, `"angry"`). Custom emoji reactions do not exist in Teams. +- **Event handler context shape**: Slack event handlers receive `{ event, say, client }`. Teams handlers receive `{ activity, send, reply, stream }`. Do not try to destructure Slack property names from Teams handlers. +- **No `client` equivalent for arbitrary API calls**: Slack's `client.chat.postMessage()` for posting to other channels maps to `app.send(conversationId, text)` in Teams. Store conversation IDs at install time for proactive messaging. +- **Reaction-based workflows break silently**: A Slack bot using `:white_check_mark:` reactions as approval triggers will not error in Teams — it simply never fires because the custom emoji doesn't exist. Audit all `reaction_added` handlers for custom emoji names before migration. +- **No `reply_broadcast` equivalent**: Slack's "also send to channel" flag on threaded replies has no Teams counterpart. If the bot relies on broadcasting thread replies to the main channel, you must send two separate messages: a `reply()` to the thread and a `send()` to the channel. +- **Thread discovery requires Graph API with app permissions**: Fetching thread replies in Teams requires calling the Graph API (`/messages/{id}/replies`) with `ChannelMessage.Read.All` application-level permission. This is a significant permission escalation compared to Slack's `conversations.replies` which uses the standard bot token. + +## references + +- https://learn.microsoft.com/en-us/microsoftteams/platform/bots/how-to/conversations/subscribe-to-conversation-events +- https://learn.microsoft.com/en-us/microsoftteams/platform/bots/how-to/conversations/channel-and-group-conversations +- https://learn.microsoft.com/en-us/microsoftteams/platform/bots/how-to/conversations/conversation-basics +- https://learn.microsoft.com/en-us/microsoftteams/platform/bots/how-to/conversations/send-proactive-messages +- https://learn.microsoft.com/en-us/microsoftteams/platform/graph-api/rsc/resource-specific-consent +- https://learn.microsoft.com/en-us/microsoftteams/platform/tabs/what-are-tabs +- https://github.com/microsoft/teams.ts +- https://slack.dev/bolt-js/concepts/events +- https://slack.dev/bolt-js/concepts/message-listening + +## instructions + +This expert covers bridging Slack event subscriptions and Teams activity handlers. Use it when adding cross-platform support in either direction: mapping Slack events (app_mention, member_joined_channel, member_left_channel, reaction_added, reaction_removed, message_changed, message_deleted, app_home_opened) to their Teams equivalents (message, conversationUpdate, messageReaction, messageUpdate, messageDelete, tab.open, install.add) or vice versa; converting between `say()`/`send()` and `reply()`/threaded patterns; handling ephemeral message differences; understanding the @mention requirement in Teams channels vs Slack's default all-message delivery; and mapping event payload properties between platforms. The comprehensive mapping table and reverse-direction section provide a quick reference for bridging in both directions. Pair with `../slack/runtime.bolt-foundations-ts.md` for Slack event patterns, and `../teams/runtime.routing-handlers-ts.md` for Teams activity routes. + +## research + +Deep Research prompt: + +"Write a micro expert for bridging Slack events and Teams activity routes bidirectionally. Cover all major Slack events (app_mention, member_joined_channel, member_left_channel, reaction_added, reaction_removed, message subtypes, app_home_opened) with their Teams equivalents (message, conversationUpdate, messageReaction, messageUpdate, messageDelete, typing, install events) and vice versa. Include side-by-side TypeScript code examples, a comprehensive bidirectional mapping table, payload shape differences, the @mention requirement in channels, ephemeral message handling strategies, and common pitfalls for both directions." diff --git a/plugins/microsoft-365-agents-toolkit/skills/teams-app-developer/experts/bridge/files-upload-download-ts.md b/plugins/microsoft-365-agents-toolkit/skills/teams-app-developer/experts/bridge/files-upload-download-ts.md new file mode 100644 index 0000000..6f5bb46 --- /dev/null +++ b/plugins/microsoft-365-agents-toolkit/skills/teams-app-developer/experts/bridge/files-upload-download-ts.md @@ -0,0 +1,332 @@ +# files-upload-download-ts + +## purpose + +Bridges Slack file operations (files.upload, file events) and Teams file consent / OneDrive/SharePoint patterns for cross-platform bots targeting Slack, Teams, or both. + +## rules + +1. **Slack `files.upload` → Teams FileConsentCard + Graph API upload.** Slack bots upload files directly via `files.upload`. Teams bots cannot directly attach files to messages. Instead: (a) send a FileConsentCard asking the user for upload consent, (b) on consent, upload the file to the user's OneDrive via Graph API, (c) send a file info card with the download link. [learn.microsoft.com -- Send files](https://learn.microsoft.com/en-us/microsoftteams/platform/bots/how-to/bots-filesv4) +2. **The `supportsFiles: true` manifest flag is required.** Without `"supportsFiles": true` in the bot's manifest entry, Teams will not show file consent cards or allow the bot to handle file-related activities. This flag only works in personal (1:1) scope. [learn.microsoft.com -- Bot manifest](https://learn.microsoft.com/en-us/microsoftteams/platform/resources/schema/manifest-schema#bots) +3. **Slack `files.sharedPublicURL` → Graph API `createLink` sharing link.** Slack creates a public URL for a file. In Teams/OneDrive, use the Graph API `POST /drives/{drive-id}/items/{item-id}/createLink` to create a sharing link with the desired permission scope (view, edit, anonymous). [learn.microsoft.com -- Create sharing link](https://learn.microsoft.com/en-us/graph/api/driveitem-createlink) +4. **Slack file events (`file_shared`, `file_created`) → `activity.attachments` in message handler.** When a user sends a file to a Teams bot, the file appears as an attachment on the incoming message activity. Check `activity.attachments` for items with `contentType` of `application/vnd.microsoft.teams.file.download.info`. [learn.microsoft.com -- Receive files](https://learn.microsoft.com/en-us/microsoftteams/platform/bots/how-to/bots-filesv4#receive-files-in-personal-chat) +5. **Download user-uploaded files via the `downloadUrl` in the attachment.** Each file attachment includes a `content.downloadUrl` with a pre-authenticated URL. Use `fetch()` or `axios` to download the file content. The URL is short-lived — download immediately in the handler. [learn.microsoft.com -- File download](https://learn.microsoft.com/en-us/microsoftteams/platform/bots/how-to/bots-filesv4) +6. **File upload in channels requires SharePoint, not OneDrive.** In personal chat, files go to the user's OneDrive. In channels, files go to the team's SharePoint document library. The Graph API path changes: `POST /drives/{drive-id}/root:/{folder}/{filename}:/content` where the drive is the channel's SharePoint drive. [learn.microsoft.com -- SharePoint files](https://learn.microsoft.com/en-us/graph/api/driveitem-put-content) +7. **Large files (>4 MB) require Graph resumable upload sessions.** Small files can use simple PUT to Graph API. Files larger than 4 MB must use a resumable upload session: `POST /drives/{drive-id}/items/{parent-id}:/filename:/createUploadSession`, then upload in 320 KB–60 MB chunks. Slack's `files.upload` handled this transparently. [learn.microsoft.com -- Resumable upload](https://learn.microsoft.com/en-us/graph/api/driveitem-createuploadsession) +8. **FileConsentCard flow is a 3-step protocol.** Step 1: Bot sends a FileConsentCard with filename and size. Step 2: User accepts or declines. Step 3: On accept, Teams sends a `fileConsent/invoke` activity with an `uploadInfo` containing the upload URL. On decline, Teams sends the same invoke with a `declined` action. Handle both cases. [learn.microsoft.com -- File consent](https://learn.microsoft.com/en-us/microsoftteams/platform/bots/how-to/bots-filesv4#send-files-to-personal-chat) +9. **Slack `files.list` / `files.info` → Graph API drive item queries.** Slack has dedicated file listing APIs. In Teams, files are stored in OneDrive/SharePoint. Use Graph API: `GET /drives/{drive-id}/root/children` to list files, `GET /drives/{drive-id}/items/{item-id}` for file metadata. [learn.microsoft.com -- List items](https://learn.microsoft.com/en-us/graph/api/driveitem-list-children) +10. **File handling only works in personal (1:1) chat scope.** The `supportsFiles` manifest flag and FileConsentCard only work in personal bot conversations. For channel file operations, use Graph API directly without the consent card flow. This is a significant scope limitation compared to Slack where `files.upload` works in any channel. [learn.microsoft.com -- Bot files](https://learn.microsoft.com/en-us/microsoftteams/platform/bots/how-to/bots-filesv4) +11. **Reverse direction (Teams → Slack):** For Teams → Slack, the reverse is simpler: Slack's `files.uploadV2` is a direct single-call API vs Teams' multi-step consent flow. Map OneDrive/SharePoint file URLs to `files.uploadV2` with a buffer, Graph `createLink` sharing links to `files.sharedPublicURL`, and `activity.attachments` file downloads to Slack `file_shared` event handling. The Slack API handles storage transparently. + +## patterns + +### Upload a file with FileConsentCard (replaces files.upload) + +**Slack (before):** + +```typescript +import { App } from "@slack/bolt"; +import fs from "fs"; + +const app = new App({ + token: process.env.SLACK_BOT_TOKEN!, + signingSecret: process.env.SLACK_SIGNING_SECRET!, +}); + +app.command("/export", async ({ ack, command, client }) => { + await ack(); + const csvData = await generateReport(); + + // Direct file upload — Slack handles storage + await client.files.uploadV2({ + channel_id: command.channel_id, + filename: "report.csv", + file: Buffer.from(csvData), + title: "Monthly Report", + initial_comment: "Here's your report!", + }); +}); +``` + +**Teams (after):** + +```typescript +import { App } from "@microsoft/teams.apps"; +import { ConsoleLogger } from "@microsoft/teams.common"; + +const app = new App({ + logger: new ConsoleLogger("my-bot", { level: "info" }), +}); + +// Store pending uploads keyed by conversation +const pendingUploads = new Map(); + +// Step 1: Send FileConsentCard (replaces files.upload) +app.message(/^\/?export$/i, async ({ send, activity }) => { + const csvData = await generateReport(); + const csvBuffer = Buffer.from(csvData); + const convId = activity.conversation?.id ?? ""; + + // Store the file content for later upload + pendingUploads.set(convId, csvBuffer); + + // Send consent card — user must approve the upload + await send({ + attachments: [{ + contentType: "application/vnd.microsoft.teams.card.file.consent", + name: "report.csv", + content: { + description: "Monthly Report — click Accept to save to your OneDrive", + sizeInBytes: csvBuffer.length, + acceptContext: { filename: "report.csv" }, + declineContext: { filename: "report.csv" }, + }, + }], + }); +}); + +// Step 2: Handle consent response +app.on("fileConsent" as any, async ({ activity, send }) => { + const action = activity.value?.action; + const convId = activity.conversation?.id ?? ""; + + if (action === "accept") { + // Step 3: Upload file to the URL provided by Teams + const uploadInfo = activity.value?.uploadInfo; + const fileContent = pendingUploads.get(convId); + + if (uploadInfo && fileContent) { + // Upload to OneDrive via the pre-signed URL + await fetch(uploadInfo.uploadUrl, { + method: "PUT", + headers: { "Content-Type": "application/octet-stream" }, + body: fileContent, + }); + + // Send confirmation with file card + await send({ + attachments: [{ + contentType: "application/vnd.microsoft.teams.card.file.info", + name: "report.csv", + contentUrl: uploadInfo.contentUrl, + content: { + uniqueId: uploadInfo.uniqueId, + fileType: "csv", + }, + }], + }); + } + pendingUploads.delete(convId); + } else { + await send("File upload cancelled."); + pendingUploads.delete(convId); + } +}); + +async function generateReport(): Promise { + return "Name,Status\nServer1,OK\nServer2,Down"; +} + +app.start(3978); +``` + +### Receive and process user-uploaded files + +**Slack (before):** + +```typescript +import { App } from "@slack/bolt"; + +const app = new App({ + token: process.env.SLACK_BOT_TOKEN!, + signingSecret: process.env.SLACK_SIGNING_SECRET!, +}); + +app.event("file_shared", async ({ event, client }) => { + const fileInfo = await client.files.info({ file: event.file_id }); + const file = fileInfo.file!; + + // Download file content using the private URL + bot token + const response = await fetch(file.url_private!, { + headers: { Authorization: `Bearer ${process.env.SLACK_BOT_TOKEN}` }, + }); + const content = await response.text(); + + await client.chat.postMessage({ + channel: event.channel_id, + text: `Received ${file.name} (${file.size} bytes). Processing...`, + }); + + // Process the file content... +}); +``` + +**Teams (after):** + +```typescript +import { App } from "@microsoft/teams.apps"; +import { ConsoleLogger } from "@microsoft/teams.common"; + +const app = new App({ + logger: new ConsoleLogger("my-bot", { level: "info" }), +}); + +// Files arrive as attachments on regular message activities +app.on("message", async ({ activity, send }) => { + const fileAttachments = activity.attachments?.filter( + (a: any) => a.contentType === "application/vnd.microsoft.teams.file.download.info" + ); + + if (fileAttachments?.length) { + for (const attachment of fileAttachments) { + const downloadUrl = attachment.content?.downloadUrl; + const fileName = attachment.name; + + if (downloadUrl) { + // Download using the pre-authenticated URL (no token needed) + const response = await fetch(downloadUrl); + const content = await response.text(); + + await send(`Received ${fileName} (${content.length} chars). Processing...`); + // Process the file content... + } + } + } +}); + +app.start(3978); +``` + +### Reusable `sendFile()` helper (Y4/5/6 best practice) + +Build a unified helper that auto-detects personal vs. channel context and handles chunking. This eliminates the 30-line FileConsentCard footgun. + +```typescript +import { Client } from "@microsoft/microsoft-graph-client"; + +interface SendFileOptions { + filename: string; + content: Buffer; + description?: string; +} + +async function sendFile( + ctx: { send: (msg: any) => Promise; activity: any }, + graphClient: Client, + options: SendFileOptions +): Promise { + const { filename, content, description } = options; + const conversationType = ctx.activity.conversation?.conversationType; + + if (conversationType === "personal") { + // Personal chat → FileConsentCard flow + await ctx.send({ + attachments: [{ + contentType: "application/vnd.microsoft.teams.card.file.consent", + name: filename, + content: { + description: description ?? filename, + sizeInBytes: content.length, + acceptContext: { filename, size: content.length }, + declineContext: { filename }, + }, + }], + }); + // Store content for the fileConsent handler to pick up + pendingUploads.set(ctx.activity.conversation?.id ?? "", { content, filename }); + } else { + // Channel → Direct Graph API upload to SharePoint + const teamId = ctx.activity.channelData?.teamsTeamId; + const channelId = ctx.activity.channelData?.teamsChannelId; + const driveId = await getChannelDriveId(graphClient, teamId, channelId); + + if (content.length <= 4 * 1024 * 1024) { + // Small file: simple PUT + await graphClient + .api(`/drives/${driveId}/root:/${filename}:/content`) + .put(content); + } else { + // Large file (>4 MB): resumable upload session + const session = await graphClient + .api(`/drives/${driveId}/root:/${filename}:/createUploadSession`) + .post({ item: { name: filename } }); + + const chunkSize = 320 * 1024; // 320 KB chunks + for (let offset = 0; offset < content.length; offset += chunkSize) { + const chunk = content.subarray(offset, offset + chunkSize); + const end = Math.min(offset + chunkSize, content.length); + await fetch(session.uploadUrl, { + method: "PUT", + headers: { + "Content-Range": `bytes ${offset}-${end - 1}/${content.length}`, + "Content-Type": "application/octet-stream", + }, + body: chunk, + }); + } + } + + await ctx.send(`File uploaded: ${filename}`); + } +} + +async function getChannelDriveId( + graphClient: Client, teamId: string, channelId: string +): Promise { + const response = await graphClient + .api(`/teams/${teamId}/channels/${channelId}/filesFolder`) + .get(); + return response.parentReference.driveId; +} +``` + +**Key decisions:** +- Personal chat → FileConsentCard flow (requires `supportsFiles: true` in manifest) +- Channel → Direct Graph API upload to SharePoint (no consent card) +- Files >4 MB → Graph resumable upload session with 320 KB chunks + +**Don't:** Store pending file buffers in memory for long periods. Upload promptly or stream to a temporary blob. + +**Reverse (Teams → Slack):** Use `files.uploadV2({ channel_id, file: buffer, filename })` — single call, no consent step. + +### File operation mapping table + +| Slack API | Teams Equivalent | Notes | +|---|---|---| +| `files.uploadV2(channel, file)` | FileConsentCard → Graph PUT | 3-step consent flow; personal chat only | +| `files.sharedPublicURL(file)` | Graph `createLink(type, scope)` | Creates OneDrive/SharePoint sharing link | +| `files.info(file_id)` | Graph `GET /drives/{id}/items/{id}` | File metadata from OneDrive/SharePoint | +| `files.list(channel)` | Graph `GET /drives/{id}/root/children` | List drive items | +| `file_shared` event | `activity.attachments` check in message handler | No dedicated event; check attachments on each message | +| `file.url_private` + bot token | `attachment.content.downloadUrl` | Pre-authenticated URL; no token needed | +| Large file upload | Graph resumable upload session | Required for files > 4 MB | + +## pitfalls + +- **Missing `supportsFiles: true` in manifest**: Without this flag, Teams will not render FileConsentCards and file-related invoke activities will never fire. This is the #1 cause of "file upload doesn't work" during migration. +- **FileConsentCard only works in personal (1:1) chat**: Channel bots cannot use the consent card flow. For channel file operations, upload directly via Graph API to the team's SharePoint document library — which requires different Graph API permissions and paths. +- **Download URLs are short-lived**: The `downloadUrl` in file attachments is pre-authenticated but expires. Download the file immediately in the message handler. Do not store the URL for later use. +- **Large file upload requires chunking**: Files over 4 MB cannot use simple PUT. You must create an upload session and send chunks. Slack's `files.upload` handled this transparently — Teams requires explicit chunking logic. +- **Graph API permissions required**: File operations via Graph API require `Files.ReadWrite` (delegated) or `Files.ReadWrite.All` (application) permissions. These must be configured in the Azure AD app registration and consented by an admin for application permissions. +- **No file preview in bot messages**: Slack generates inline previews for uploaded images and documents. Teams file info cards show a file icon and name but not an inline preview. For image files, consider embedding the image URL directly in an Adaptive Card `Image` element instead. + +## references + +- https://learn.microsoft.com/en-us/microsoftteams/platform/bots/how-to/bots-filesv4 +- https://learn.microsoft.com/en-us/graph/api/driveitem-put-content +- https://learn.microsoft.com/en-us/graph/api/driveitem-createuploadsession +- https://learn.microsoft.com/en-us/graph/api/driveitem-createlink +- https://learn.microsoft.com/en-us/microsoftteams/platform/resources/schema/manifest-schema#bots +- https://github.com/microsoft/teams.ts +- https://api.slack.com/methods/files.uploadV2 — Slack files.upload +- https://api.slack.com/methods/files.sharedPublicURL — Slack file sharing + +## instructions + +Use this expert when adding cross-platform support in either direction for Slack file operations or Teams file consent / OneDrive/SharePoint patterns. It covers: `files.upload` to FileConsentCard + Graph upload, `files.sharedPublicURL` to Graph sharing links, file event handling via activity attachments, large file resumable uploads, and the personal-chat-only limitation. For Teams → Slack, the reverse is simpler: Slack's `files.uploadV2` is a direct single-call API vs Teams' multi-step consent flow. Pair with `../teams/graph.usergraph-appgraph-ts.md` for Graph API authentication patterns, `../teams/runtime.manifest-ts.md` for the `supportsFiles` manifest flag, and `interactive-responses-ts.md` for the consent card invoke handling pattern. + +## research + +Deep Research prompt: + +"Write a micro expert for bridging Slack file operations (files.upload, files.sharedPublicURL, file_shared event, file download) and Teams file consent / OneDrive/SharePoint patterns in either direction for cross-platform bots. Cover FileConsentCard 3-step flow, OneDrive/SharePoint Graph API uploads, resumable upload sessions for large files, receiving files via activity attachments, the supportsFiles manifest flag, personal-chat-only limitation, Graph API permission requirements, and reverse-direction notes for Teams → Slack (simpler single-call API). Include TypeScript code examples and a mapping table." diff --git a/plugins/microsoft-365-agents-toolkit/skills/teams-app-developer/experts/bridge/identity-oauth-bridge-ts.md b/plugins/microsoft-365-agents-toolkit/skills/teams-app-developer/experts/bridge/identity-oauth-bridge-ts.md new file mode 100644 index 0000000..a8f45f1 --- /dev/null +++ b/plugins/microsoft-365-agents-toolkit/skills/teams-app-developer/experts/bridge/identity-oauth-bridge-ts.md @@ -0,0 +1,354 @@ +# identity-oauth-bridge-ts + +## purpose + +Bridges Slack and Teams/Azure AD identity systems (user/channel IDs, OAuth, signing) for cross-platform bots targeting Slack, Teams, or both. + +## rules + +1. Slack uses proprietary ID formats: user IDs start with `U` (e.g., `U01ABCDEF`), channel IDs start with `C` (e.g., `C02GHIJKL`), team/workspace IDs start with `T` (e.g., `T03MNOPQR`), and bot IDs start with `B`. These IDs have no relationship to Teams/Azure AD identifiers and cannot be mapped automatically. [api.slack.com/types](https://api.slack.com/types) +2. Teams identifies users by Azure AD Object ID (a GUID like `00000000-0000-0000-0000-000000000000`), available at `activity.from.aadObjectId`. Conversation IDs are opaque strings like `19:abc123@thread.v2` for channels or `a]concat@...` for personal chats. These formats are fundamentally different from Slack IDs. [learn.microsoft.com -- Activity schema](https://learn.microsoft.com/en-us/azure/bot-service/rest-api/bot-framework-rest-connector-api-reference) +3. Slack's request verification via `signingSecret` (HMAC-SHA256 of request body) is replaced by **Bot Framework JWT token validation** in Teams. The Teams SDK handles JWT validation automatically -- no manual signing secret check is needed. [learn.microsoft.com -- Bot authentication](https://learn.microsoft.com/en-us/azure/bot-service/rest-api/bot-framework-rest-connector-authentication) +4. Slack's bot token (`xoxb-...`) used for API calls is replaced by **Azure Bot credentials** (`CLIENT_ID` + `CLIENT_SECRET` + `TENANT_ID`). The Teams SDK uses these to obtain tokens for the Bot Framework service automatically. [learn.microsoft.com -- Register a bot](https://learn.microsoft.com/en-us/azure/bot-service/bot-service-quickstart-registration) +5. Slack OAuth scopes (e.g., `chat:write`, `users:read`, `commands`) map to **Azure AD permissions** for the Microsoft Graph API (e.g., `User.Read`, `ChannelMessage.Send`). Slack scopes are configured in the Slack app dashboard; Azure AD permissions are configured in the Azure Portal under App Registration > API Permissions. [learn.microsoft.com -- Graph permissions](https://learn.microsoft.com/en-us/graph/permissions-reference) +6. Slack user tokens (obtained via OAuth `users:read` or user token grant) map to **Teams SSO / OAuth card flow**. In Teams, configure an OAuth connection in the Azure Bot resource, then use `isSignedIn` / `signin()` / `userGraph` in handlers to access the user's delegated token for Graph API calls. [learn.microsoft.com -- Bot SSO](https://learn.microsoft.com/en-us/microsoftteams/platform/bots/how-to/authentication/bot-sso-overview) +7. To resolve user identity across platforms during migration, use a **shared attribute** like email address. Query Slack's `users.info` API for the user's email, then look up the same email in Azure AD via Microsoft Graph `users?$filter=mail eq '...'`. Build a mapping table of Slack user ID to AAD Object ID. [learn.microsoft.com -- Graph users API](https://learn.microsoft.com/en-us/graph/api/user-list) +8. Any data stored with Slack IDs as keys (user preferences, conversation history, permissions) must be **re-keyed** to Teams/AAD IDs. Plan a data migration step that uses the email-based mapping table to translate stored Slack user IDs to AAD Object IDs. [learn.microsoft.com -- Migration planning](https://learn.microsoft.com/en-us/microsoftteams/platform/concepts/deploy-and-publish/apps-upload) +9. Slack workspace-level operations (e.g., listing all users via `users.list`, posting to any channel) require bot scopes. In Teams, equivalent operations use Microsoft Graph with **application permissions** (consented by a tenant admin). Use `appGraph` for service-to-service calls and `userGraph` for delegated user calls. [learn.microsoft.com -- Graph auth overview](https://learn.microsoft.com/en-us/graph/auth/auth-concepts) +10. Teams supports **managed identity** as an alternative to client secret for production deployments on Azure. Set `managedIdentityClientId: 'system'` in App options to use Azure Managed Identity instead of storing secrets in environment variables. [learn.microsoft.com -- Managed identity](https://learn.microsoft.com/en-us/azure/active-directory/managed-identities-azure-resources/overview) + +## patterns + +### Environment variable mapping between Slack and Teams + +**Slack `.env`:** + +```env +# Slack Bot Configuration +SLACK_BOT_TOKEN=your-slack-bot-token +SLACK_SIGNING_SECRET=your-signing-secret +SLACK_APP_TOKEN=your-slack-app-token +SLACK_CLIENT_ID=your-slack-client-id +SLACK_CLIENT_SECRET=your-slack-client-secret +PORT=3000 +``` + +**Teams `.env`:** + +```env +# Azure Bot Registration +CLIENT_ID=00000000-0000-0000-0000-000000000000 +CLIENT_SECRET=your-azure-bot-client-secret +TENANT_ID=00000000-0000-0000-0000-000000000000 +PORT=3978 +``` + +**Environment variable mapping table:** + +| Slack Variable | Teams Variable | Notes | +|---|---|---| +| `SLACK_BOT_TOKEN` (`xoxb-...`) | `CLIENT_ID` + `CLIENT_SECRET` | Teams SDK manages token acquisition automatically | +| `SLACK_SIGNING_SECRET` | *(not needed)* | Bot Framework JWT validation is automatic | +| `SLACK_APP_TOKEN` (`xapp-...`) | *(not needed)* | Socket mode is Slack-only; Teams uses HTTPS | +| `SLACK_CLIENT_ID` | `CLIENT_ID` | Azure Bot App Registration ID (GUID) | +| `SLACK_CLIENT_SECRET` | `CLIENT_SECRET` | Azure Bot App Registration secret | +| *(not applicable)* | `TENANT_ID` | Azure AD tenant ID (new for Teams) | +| `PORT` (default 3000) | `PORT` (default 3978) | Different conventional defaults | + +**Identity concept mapping table:** + +| Slack Concept | Teams/Azure AD Concept | Format | +|---|---|---| +| User ID (`U01ABCDEF`) | AAD Object ID | GUID (`00000000-...`) | +| Channel ID (`C02GHIJKL`) | Conversation ID | `19:abc@thread.v2` | +| Team/Workspace ID (`T03MNOPQR`) | Tenant ID | GUID | +| Bot ID (`B04STUVWX`) | Bot ID (from App Registration) | GUID | +| DM Channel ID (`D05YZABCD`) | Personal conversation ID | Opaque string | +| Signing Secret | Bot Framework JWT | Automatic validation | +| Bot Token (`xoxb-...`) | Client credentials flow | CLIENT_ID + CLIENT_SECRET | +| User Token (`xoxp-...`) | Delegated OAuth token | SSO / OAuth card flow | +| OAuth scopes (`chat:write`) | Azure AD permissions (`ChannelMessage.Send`) | Configured in Azure Portal | +| Slack App Dashboard | Azure Portal + manifest.json | Config split between portal and file | + +### Migrating authentication from Slack OAuth to Teams SSO + +**Slack (before) -- Using Slack OAuth for user identity:** + +```typescript +import { App } from "@slack/bolt"; + +const app = new App({ + token: process.env.SLACK_BOT_TOKEN!, + signingSecret: process.env.SLACK_SIGNING_SECRET!, +}); + +app.command("/whoami", async ({ ack, command, client }) => { + await ack(); + // Use the bot token to look up user info + const userInfo = await client.users.info({ user: command.user_id }); + const email = userInfo.user?.profile?.email ?? "unknown"; + const name = userInfo.user?.real_name ?? "unknown"; + await client.chat.postMessage({ + channel: command.channel_id, + text: `You are ${name} (${email})`, + }); +}); +``` + +**Teams (after) -- Using Teams SSO and Microsoft Graph:** + +```typescript +import { App } from "@microsoft/teams.apps"; +import { ConsoleLogger } from "@microsoft/teams.common"; +import { DevtoolsPlugin } from "@microsoft/teams.dev"; +import * as endpoints from "@microsoft/teams.graph-endpoints"; + +const app = new App({ + clientId: process.env.CLIENT_ID, + clientSecret: process.env.CLIENT_SECRET, + tenantId: process.env.TENANT_ID, + logger: new ConsoleLogger("my-bot", { level: "info" }), + plugins: [new DevtoolsPlugin()], + oauth: { defaultConnectionName: "graph" }, +}); + +app.message(/^\/?whoami$/i, async ({ isSignedIn, signin, userGraph, send }) => { + // If user is not signed in, trigger the SSO/OAuth flow + if (!isSignedIn) { + await signin({ signInButtonText: "Sign In to continue" }); + return; + } + + // Use the delegated Graph client to get user profile + const me = await userGraph.call(endpoints.me.get); + await send(`You are ${me.displayName} (${me.mail})`); +}); + +// Handle successful sign-in +app.event("signin", async ({ send, userGraph }) => { + const me = await userGraph.call(endpoints.me.get); + await send(`Welcome, ${me.displayName}! You are now signed in.`); +}); + +app.start(3978); +``` + +### Building a Slack-to-AAD user ID mapping table + +```typescript +import { WebClient } from "@slack/web-api"; +import { Client as GraphClient } from "@microsoft/microsoft-graph-client"; + +interface UserMapping { + slackUserId: string; + slackEmail: string; + aadObjectId: string | null; + aadDisplayName: string | null; +} + +async function buildUserMappingTable( + slackClient: WebClient, + graphClient: GraphClient +): Promise { + const mappings: UserMapping[] = []; + + // Step 1: Fetch all Slack users + const slackUsers = await slackClient.users.list({}); + const members = slackUsers.members ?? []; + + for (const slackUser of members) { + if (slackUser.deleted || slackUser.is_bot) continue; + + const email = slackUser.profile?.email; + if (!email) { + mappings.push({ + slackUserId: slackUser.id!, + slackEmail: "", + aadObjectId: null, + aadDisplayName: null, + }); + continue; + } + + // Step 2: Look up the same email in Azure AD via Graph + try { + const result = await graphClient + .api("/users") + .filter(`mail eq '${email}' or userPrincipalName eq '${email}'`) + .select("id,displayName,mail") + .get(); + + const aadUser = result.value?.[0]; + mappings.push({ + slackUserId: slackUser.id!, + slackEmail: email, + aadObjectId: aadUser?.id ?? null, + aadDisplayName: aadUser?.displayName ?? null, + }); + } catch { + mappings.push({ + slackUserId: slackUser.id!, + slackEmail: email, + aadObjectId: null, + aadDisplayName: null, + }); + } + } + + return mappings; +} + +// Step 3: Use the mapping to re-key stored data +async function migrateUserData( + mappings: UserMapping[], + oldStore: Map, + newStore: Map +): Promise { + for (const mapping of mappings) { + if (!mapping.aadObjectId) continue; + const data = oldStore.get(mapping.slackUserId); + if (data) { + newStore.set(mapping.aadObjectId, data); + } + } +} +``` + +### Converting Slack OAuth implementation code to Teams OAuth + +Slack SDKs (especially `java-slack-sdk` and `@slack/bolt`) implement OAuth with explicit services: `InstallationService` for storing tokens, `OAuthStateService` for CSRF, and `OAuthCallbackHandler` for the redirect. Teams replaces ALL of this with declarative config. + +**Slack Java SDK OAuth (before):** + +```java +// --- Slack Java SDK OAuth implementation --- +// InstallationService — stores bot tokens per workspace +public class FileInstallationService implements InstallationService { + public void saveInstallerAndBot(Installer installer) { /* persist to DB */ } + public Installer findInstaller(String enterpriseId, String teamId) { /* lookup */ } + public Bot findBot(String enterpriseId, String teamId) { /* lookup */ } + public void deleteBot(Bot bot) { /* remove */ } + public void deleteInstaller(Installer installer) { /* remove */ } +} + +// OAuthStateService — generates and validates CSRF state parameter +public class FileOAuthStateService implements OAuthStateService { + public String issueNewState(Request req) { /* generate random state */ } + public boolean isValid(OAuthState state) { /* validate state */ } + public void consume(OAuthState state) { /* mark used */ } +} + +// App configuration with OAuth +App app = new App(AppConfig.builder() + .clientId(System.getenv("SLACK_CLIENT_ID")) + .clientSecret(System.getenv("SLACK_CLIENT_SECRET")) + .signingSecret(System.getenv("SLACK_SIGNING_SECRET")) + .oAuthInstallPath("/slack/install") + .oAuthRedirectUriPath("/slack/oauth_redirect") + .oAuthCompletionUrl("https://example.com/success") + .oAuthCancellationUrl("https://example.com/cancel") + .installationService(new FileInstallationService()) + .oauthStateService(new FileOAuthStateService()) + .build()); +``` + +**Teams OAuth (after):** + +```typescript +// --- Teams OAuth — all of the above is replaced by config --- +import { App } from '@microsoft/teams.apps'; +import { ConsoleLogger } from '@microsoft/teams.common'; + +const app = new App({ + clientId: process.env.CLIENT_ID, + clientSecret: process.env.CLIENT_SECRET, + tenantId: process.env.TENANT_ID, + logger: new ConsoleLogger('my-bot', { level: 'info' }), + + // This single config block replaces: + // - InstallationService (token storage is managed by Azure Bot Service) + // - OAuthStateService (CSRF handled by Bot Framework) + // - OAuthCallbackHandler (redirect handled by Azure Bot Service) + // - Token refresh logic (managed by Azure Bot Service) + oauth: { + defaultConnectionName: 'graph', // configured in Azure Portal + // That's it. No custom services needed. + }, +}); + +// Instead of Slack's multi-step OAuth flow with custom storage: +// 1. Azure Bot Service manages token acquisition, refresh, and storage +// 2. CSRF protection is built into the Bot Framework sign-in flow +// 3. The OAuth connection is configured in Azure Portal (not code) +// 4. Use isSignedIn/signin() in handlers to trigger auth when needed + +app.message(/^profile$/i, async ({ isSignedIn, signin, userGraph, send }) => { + if (!isSignedIn) { + await signin(); + return; + } + const me = await userGraph.call(endpoints.me.get); + await send(`Signed in as ${me.displayName}`); +}); +``` + +**What gets DELETED during conversion:** + +| Slack OAuth Component | Teams Equivalent | Action | +|---|---|---| +| `InstallationService` + DB storage | Azure Bot Service token cache | Delete entirely | +| `OAuthStateService` + CSRF tokens | Bot Framework built-in CSRF | Delete entirely | +| `OAuthCallbackHandler` + redirect routes | Azure Bot Service callbacks | Delete entirely | +| Token refresh / expiry logic | Azure Bot Service auto-refresh | Delete entirely | +| `/slack/install` route | Teams app install flow | Delete entirely | +| `/slack/oauth_redirect` route | Azure Bot Service | Delete entirely | +| Multi-workspace token lookup | Managed identity / tenant config | Delete entirely | +| Slack OAuth scopes in code | Azure Portal API Permissions | Configure in portal | + +### Reverse direction (Teams → Slack) + +For Teams → Slack, the same mapping table applies in reverse. AAD Object IDs need mapping to Slack user IDs via email lookup. Key reverse mappings: +- `activity.from.aadObjectId` (GUID) → Slack User ID (`U...`) via email-based lookup: query Graph `users/{aadObjectId}` for email, then `users.lookupByEmail` in Slack +- `activity.conversation.id` (`19:abc@thread.v2`) → Slack Channel ID (`C...`) via channel name mapping or a stored lookup table +- `CLIENT_ID` + `CLIENT_SECRET` + `TENANT_ID` → `SLACK_BOT_TOKEN` (`xoxb-...`) + `SLACK_SIGNING_SECRET` +- Azure AD permissions (`ChannelMessage.Send`, `User.Read`) → Slack OAuth scopes (`chat:write`, `users:read`) +- Teams SSO / OAuth card flow → Slack OAuth with `InstallationService` and `OAuthStateService` (Slack requires explicit token storage and refresh logic that Azure Bot Service handles automatically) +- Bot Framework JWT validation (automatic) → Slack signing secret HMAC-SHA256 verification (must add `signingSecret` to Bolt config) +- Azure Managed Identity → no Slack equivalent; use environment variables or secret manager for Slack tokens +- The email-based user mapping table built for Slack → Teams works identically in reverse + +## pitfalls + +- **Assuming Slack IDs can be reused**: Slack IDs (`U...`, `C...`, `T...`) are completely incompatible with Teams/AAD IDs. Any code that stores or references Slack IDs must be updated to use AAD Object IDs and conversation IDs. +- **Manual signing secret validation**: Developers sometimes port Slack's HMAC verification middleware to Teams. This is unnecessary -- the Bot Framework validates JWT tokens automatically. Remove all signing secret verification code. +- **Expecting ephemeral identity context**: Slack's `user_id` is always present in command and action payloads. In Teams, `activity.from.aadObjectId` may be `undefined` in some contexts (e.g., webhook-originated activities). Always null-check. +- **OAuth scope confusion**: Slack scopes like `chat:write` do not map 1:1 to Azure AD permissions. Audit each Slack scope used and find the equivalent Graph permission. Some Slack capabilities require multiple Graph permissions or a different API approach entirely. +- **Storing tokens insecurely**: Slack bot tokens are long-lived strings. Azure Bot credentials use short-lived JWT tokens managed by the SDK. Never try to cache or store Bot Framework tokens manually. +- **Skipping the user mapping step**: Without building a Slack-to-AAD mapping table, any user-specific data (preferences, history, permissions) stored under Slack IDs becomes inaccessible. Plan this migration step early. +- **Tenant ID confusion**: Slack workspaces have a single team ID. Azure AD tenants can contain multiple Teams organizations. Ensure `TENANT_ID` is set correctly -- use the specific tenant ID for single-tenant apps or `common` for multi-tenant. +- **Forgetting to configure OAuth connection**: Teams SSO requires an OAuth connection configured in the Azure Bot resource (Settings > OAuth Connection Settings). Without it, `signin()` calls fail silently. +- **Porting OAuth implementation code instead of deleting it**: Slack's `InstallationService`, `OAuthStateService`, and `OAuthCallbackHandler` have NO Teams equivalent. Azure Bot Service handles token storage, CSRF, and callbacks automatically. Attempting to port these services wastes effort and introduces bugs. Delete them entirely and use the `oauth: { defaultConnectionName }` config. +- **Custom token refresh logic**: Slack apps often implement manual token refresh with `oauth.v2.access`. Azure Bot Service refreshes tokens automatically. Delete all refresh code. + +## references + +- https://learn.microsoft.com/en-us/azure/bot-service/rest-api/bot-framework-rest-connector-authentication +- https://learn.microsoft.com/en-us/azure/bot-service/bot-service-quickstart-registration +- https://learn.microsoft.com/en-us/microsoftteams/platform/bots/how-to/authentication/bot-sso-overview +- https://learn.microsoft.com/en-us/graph/permissions-reference +- https://learn.microsoft.com/en-us/graph/api/user-list +- https://learn.microsoft.com/en-us/graph/auth/auth-concepts +- https://learn.microsoft.com/en-us/azure/active-directory/managed-identities-azure-resources/overview +- https://api.slack.com/types +- https://api.slack.com/methods/users.info +- https://github.com/microsoft/teams.ts + +## instructions + +This expert covers bridging Slack and Teams/Azure AD identity and authentication systems. Use it when adding cross-platform support in either direction: understanding the differences between Slack IDs (U/C/T/B prefixed) and Teams IDs (AAD Object IDs, conversation IDs); bridging signing/verification (Slack signing secret ↔ Bot Framework JWT); mapping environment variables (SLACK_BOT_TOKEN, SLACK_SIGNING_SECRET ↔ CLIENT_ID, CLIENT_SECRET, TENANT_ID); converting between Slack OAuth and Teams SSO with Microsoft Graph; building a bidirectional user mapping table using email as the shared attribute; bridging Slack OAuth scopes and Azure AD permissions; and configuring authentication for either platform. Pair with `../teams/auth.oauth-sso-ts.md` for Teams OAuth/SSO flow, and `../teams/graph.usergraph-appgraph-ts.md` for Graph API user lookup during identity mapping. + +## research + +Deep Research prompt: + +"Write a micro expert for bridging Slack and Teams/Azure AD identity systems bidirectionally. Cover Slack ID formats (U/C/T/B IDs) vs Teams IDs (AAD Object IDs, conversation IDs), signing/verification bridging (signing secret <-> Bot Framework JWT), environment variable mapping in both directions, Slack OAuth <-> Teams SSO flow, Slack scopes <-> Azure AD Graph permissions, building a bidirectional user mapping table via email lookup, data re-keying strategies, and managed identity for production. Include mapping tables and TypeScript examples." diff --git a/plugins/microsoft-365-agents-toolkit/skills/teams-app-developer/experts/bridge/index.md b/plugins/microsoft-365-agents-toolkit/skills/teams-app-developer/experts/bridge/index.md new file mode 100644 index 0000000..6f0259e --- /dev/null +++ b/plugins/microsoft-365-agents-toolkit/skills/teams-app-developer/experts/bridge/index.md @@ -0,0 +1,215 @@ +# bridge-router + +## purpose + +Route cross-platform bridging tasks to the minimal set of micro-expert files. Each expert covers bridging between Slack and Teams (or AWS and Azure) in either direction. Read only the clusters that match the user's request. + +## task clusters + +### Block Kit <-> Adaptive Cards +When: converting Block Kit JSON to Adaptive Card JSON or vice versa, mapping Slack blocks to card elements, mapping Adaptive Card elements to Block Kit blocks +Read: +- `ui-block-kit-adaptive-cards-ts.md` +Cross-domain deps: `../slack/ui.block-kit-ts.md` (Slack Block Kit patterns), `../teams/ui.adaptive-cards-ts.md` (Teams Adaptive Card patterns) + +### Commands: Slash <-> Text +When: bridging slash commands between Slack and Teams, command registration differences, porting commands in either direction +Read: +- `commands-slash-text-ts.md` +Cross-domain deps: `../slack/runtime.slash-commands-ts.md` (Slack command patterns), `../teams/runtime.routing-handlers-ts.md` (Teams app.message() patterns) + +### Events <-> Activities +When: mapping Slack events to Teams activity handlers or vice versa, event model differences +Read: +- `events-activities-ts.md` +Cross-domain deps: `../slack/runtime.bolt-foundations-ts.md` (Slack event patterns), `../teams/runtime.routing-handlers-ts.md` (Teams activity routes) + +### Identity & OAuth Bridge +When: bridging Slack OAuth/identity and Azure AD/Entra ID, user mapping, SSO, OAuth implementation code (InstallationService, OAuthStateService, token refresh) +Read: +- `identity-oauth-bridge-ts.md` +Cross-domain deps: `../teams/auth.oauth-sso-ts.md` (Teams OAuth/SSO flow), `../teams/graph.usergraph-appgraph-ts.md` (Graph API for user lookup) + +### Middleware <-> Handlers +When: converting Slack Bolt middleware chains to Teams handler patterns or vice versa, porting global/listener middleware, removing or adding ack() +Read: +- `middleware-handlers-ts.md` +Cross-domain deps: `../slack/runtime.bolt-foundations-ts.md` (Slack middleware patterns), `../teams/runtime.routing-handlers-ts.md` (Teams handler patterns) + +### Modals <-> Dialogs +When: bridging Slack modals (views.open, viewSubmission, viewsUpdate, viewClosed, blockSuggestion in modals) and Teams task module / dialog flows +Read: +- `ui-modals-dialogs-ts.md` +Cross-domain deps: `../teams/ui.dialogs-task-modules-ts.md` (Teams dialog patterns), `ui-block-kit-adaptive-cards-ts.md` (converting modal UI between Block Kit and Adaptive Cards) + +### App Home <-> Personal Tab +When: bridging Slack App Home tab (AppHomeOpenedEvent, views.publish) and Teams personal tab or bot welcome card +Read: +- `ui-app-home-personal-tab-ts.md` +Cross-domain deps: `events-activities-ts.md` (event mapping), `../teams/ui.adaptive-cards-ts.md` (card construction), `../teams/runtime.proactive-messaging-ts.md` (background updates) + +### Legacy Attachments <-> Cards +When: bridging pre-Block Kit legacy Slack attachments (callback_id, color, actions, attachmentAction) and Adaptive Cards +Read: +- `ui-legacy-attachments-cards-ts.md` +Cross-domain deps: `../teams/ui.adaptive-cards-ts.md` (Teams card patterns) + +### Transport: Socket Mode <-> HTTPS +When: bridging Slack Socket Mode, RTM, or HTTP Events API and Teams Bot Framework HTTPS transport +Read: +- `transport-socketmode-https-ts.md` +Cross-domain deps: `../teams/runtime.app-init-ts.md` (Teams app startup), `../teams/dev.debug-test-ts.md` (ngrok/Dev Tunnels setup) + +### Infrastructure: Compute +When: bridging Lambda and Azure Functions, compute migration, serverless porting in either direction +Read: +- `infra-compute-ts.md` +- `infra-secrets-config-ts.md` (App Settings / env vars needed for compute config) + +### Infrastructure: Storage +When: bridging S3 and Blob Storage, DynamoDB and Cosmos DB, storage migration in either direction +Read: +- `infra-storage-ts.md` +Cross-domain deps: `../teams/state.storage-patterns-ts.md` (IStorage interface for bot state on Cosmos DB) + +### Infrastructure: Secrets & Config +When: bridging AWS Secrets Manager and Azure Key Vault, SSM and App Configuration +Read: +- `infra-secrets-config-ts.md` +Cross-domain deps: `../security/secrets-ts.md` (secrets management best practices) + +### Infrastructure: Observability +When: bridging CloudWatch and Application Insights, X-Ray and Azure Monitor, logging migration +Read: +- `infra-observability-ts.md` +Cross-domain deps: `../teams/dev.debug-test-ts.md` (Teams SDK logging with ConsoleLogger) + +### Interactive Responses +When: bridging respond({ replace_original }), respond({ delete_original }), chat.update, chat.postEphemeral, deferred responses, response_url patterns between Slack and Teams +Read: +- `interactive-responses-ts.md` +Cross-domain deps: `../teams/ui.adaptive-cards-ts.md` (card construction), `../teams/runtime.proactive-messaging-ts.md` (deferred update infrastructure) + +### Files: Upload & Download +When: bridging files.upload, files.sharedPublicURL, file events, file download/upload patterns between platforms +Read: +- `files-upload-download-ts.md` +Cross-domain deps: `../teams/graph.usergraph-appgraph-ts.md` (Graph API auth), `../teams/runtime.manifest-ts.md` (supportsFiles flag) + +### Link Unfurl <-> Preview +When: bridging link_shared event and chat.unfurl() (Slack) with link preview cards (Teams) +Read: +- `link-unfurl-preview-ts.md` +Cross-domain deps: `../teams/ui.message-extensions-ts.md` (message extension patterns), `../teams/runtime.manifest-ts.md` (messageHandlers domain config) + +### Shortcuts <-> Extensions +When: bridging Slack global shortcuts and message shortcuts with Teams message extensions or compose extensions +Read: +- `shortcuts-extensions-ts.md` +Cross-domain deps: `../teams/ui.message-extensions-ts.md` (message extension patterns), `../teams/ui.dialogs-task-modules-ts.md` (task module details) + +### Scheduling & Deferred Send +When: bridging chat.scheduleMessage, chat.deleteScheduledMessage, reminders.add, timer-based patterns between platforms +Read: +- `scheduling-deferred-send-ts.md` +Cross-domain deps: `../teams/runtime.proactive-messaging-ts.md` (proactive send infrastructure), `../teams/state.storage-patterns-ts.md` (persisting scheduled items) + +### Channel Ops <-> Graph +When: bridging conversations.create, conversations.archive, conversations.invite, conversations.kick, conversations.setTopic via Graph API +Read: +- `channel-ops-graph-ts.md` +Cross-domain deps: `../teams/graph.usergraph-appgraph-ts.md` (Graph API auth), `identity-oauth-bridge-ts.md` (user ID mapping) + +### Workflows <-> Automation +When: bridging Slack Workflow Builder workflows, custom workflow steps (workflow_step_execute), and Power Automate flows +Read: +- `workflows-automation-ts.md` +Cross-domain deps: `../teams/ui.adaptive-cards-ts.md` (card construction for bot-driven workflows), `../teams/runtime.proactive-messaging-ts.md` (flow-triggered bot messages) + +### Composable Workflow Platform +When: composable workflow architecture, reusable workflow engine, WorkflowDefinition, template workflows, five-element lifecycle, workflow platform design, workflow operating layer +Read: +- `workflow.composable-platform-ts.md` +Cross-domain deps: `../teams/workflow.sharepoint-lists-ts.md` (state), `../teams/workflow.message-native-records-ts.md` (visibility), `../teams/workflow.triggers-compose-ts.md` (triggers), `../teams/ai.conversational-query-ts.md` (intelligence), `../teams/workflow.approvals-inline-ts.md` (routing) + +### App Distribution & Packaging +When: bridging Slack App Directory listing, OAuth install flow, InstallationStore, org-level installs and Teams sideloading, app packaging, Teams Admin Center +Read: +- `app-distribution-packaging-ts.md` +Cross-domain deps: `identity-oauth-bridge-ts.md` (identity model bridge), `../teams/runtime.manifest-ts.md` (Teams manifest creation) + +### Rate Limiting & Resilience +When: bridging rate limiting patterns, retry logic, throttling handling, proactive broadcast resilience, circuit breaker between platforms +Read: +- `rate-limiting-resilience-ts.md` +Cross-domain deps: `../teams/runtime.proactive-messaging-ts.md` (proactive send infrastructure), `../teams/graph.usergraph-appgraph-ts.md` (Graph API throttling) + +### Cross-Platform Advisor +When: starting a cross-platform bridging project, assessing scope, making bridging decisions, "help me add Teams", "help me add Slack", "help me migrate", "what do I need to do to bridge" +Read: +- `cross-platform-advisor-ts.md` +Note: This expert orchestrates the full bridging workflow — it detects direction, scans the codebase, classifies the bot profile, walks through decisions, then routes to the individual experts above for implementation. + +### Cross-Platform Architecture +When: hosting both bots in a single server, shared Express, dual bot, single process, platform-agnostic service layer, deployment architecture +Read: +- `cross-platform-architecture-ts.md` +Cross-domain deps: `../slack/runtime.bolt-foundations-ts.md` (Slack setup), `../teams/runtime.app-init-ts.md` (Teams setup) + +### Python Cross-Platform +When: Python dual-platform, Python unified server, `slack_bolt` + `microsoft_teams`, FastAPI shared server, Python Slack + Teams, Tier 2, Python adaptation +Read: +- `python-cross-platform.md` +Cross-domain deps: `../slack/bolt-python.md` (Slack Python SDK), `../teams/teams-python.md` (Teams Python SDK), `cross-platform-architecture-ts.md` (architecture patterns to adapt) + +### REST-Only Integration +When: Java, C#, Go, Ruby, no SDK, raw HTTP, Bot Framework REST API, Slack Events API, Slack Web API, manual JWT validation, manual signature verification, language without native SDK +Read: +- `rest-only-integration-ts.md` +Cross-domain deps: `cross-platform-architecture-ts.md` (if mixing REST with TS SDK) + +### Composite: Full Slack <-> Teams Bridge +When: complete end-to-end cross-platform bridging between Slack and Teams bots +Read: +- `ui-block-kit-adaptive-cards-ts.md` +- `commands-slash-text-ts.md` +- `events-activities-ts.md` +- `identity-oauth-bridge-ts.md` +- `middleware-handlers-ts.md` +- `transport-socketmode-https-ts.md` +- `ui-modals-dialogs-ts.md` +- `ui-app-home-personal-tab-ts.md` +- `ui-legacy-attachments-cards-ts.md` +- `interactive-responses-ts.md` +- `files-upload-download-ts.md` +- `link-unfurl-preview-ts.md` +- `shortcuts-extensions-ts.md` +- `scheduling-deferred-send-ts.md` +- `channel-ops-graph-ts.md` +- `workflows-automation-ts.md` +- `app-distribution-packaging-ts.md` +- `rate-limiting-resilience-ts.md` +Cross-domain deps: `../teams/project.scaffold-files-ts.md` (scaffold the new Teams project), `../teams/runtime.app-init-ts.md` (initialize the Teams app), `../teams/runtime.manifest-ts.md` (create the Teams manifest) + +### Composite: Full AWS <-> Azure Bridge +When: complete end-to-end infrastructure bridging between AWS and Azure +Read: +- `infra-compute-ts.md` +- `infra-storage-ts.md` +- `infra-secrets-config-ts.md` +- `infra-observability-ts.md` +Cross-domain deps: `../security/secrets-ts.md` (secrets hygiene for Azure) + +## combining rule + +If a request involves both Slack↔Teams app bridging **and** AWS↔Azure infra bridging, read files from **both** composite clusters. + +## file inventory + +`app-distribution-packaging-ts.md` | `channel-ops-graph-ts.md` | `workflow.composable-platform-ts.md` | `commands-slash-text-ts.md` | `cross-platform-advisor-ts.md` | `cross-platform-architecture-ts.md` | `events-activities-ts.md` | `files-upload-download-ts.md` | `identity-oauth-bridge-ts.md` | `infra-compute-ts.md` | `infra-observability-ts.md` | `infra-secrets-config-ts.md` | `infra-storage-ts.md` | `interactive-responses-ts.md` | `link-unfurl-preview-ts.md` | `middleware-handlers-ts.md` | `python-cross-platform.md` | `rate-limiting-resilience-ts.md` | `rest-only-integration-ts.md` | `scheduling-deferred-send-ts.md` | `shortcuts-extensions-ts.md` | `transport-socketmode-https-ts.md` | `ui-app-home-personal-tab-ts.md` | `ui-block-kit-adaptive-cards-ts.md` | `ui-legacy-attachments-cards-ts.md` | `ui-modals-dialogs-ts.md` | `workflows-automation-ts.md` + + + + + + diff --git a/plugins/microsoft-365-agents-toolkit/skills/teams-app-developer/experts/bridge/infra-compute-ts.md b/plugins/microsoft-365-agents-toolkit/skills/teams-app-developer/experts/bridge/infra-compute-ts.md new file mode 100644 index 0000000..26c3d6d --- /dev/null +++ b/plugins/microsoft-365-agents-toolkit/skills/teams-app-developer/experts/bridge/infra-compute-ts.md @@ -0,0 +1,232 @@ +# infra-compute-ts + +## purpose + +Bridges AWS and Azure compute infrastructure for cross-platform bot hosting. Covers Lambda/ECS/EC2 to Azure App Service/Functions/Container Apps (and the reverse). The common direction is AWS → Azure, but the service mappings apply bidirectionally. + +> **Note:** AWS → Azure is the most common direction for this expert. For Azure → AWS, reverse the mappings: App Service → EC2/ECS, Azure Functions → Lambda + API Gateway, Container Apps → ECS/Fargate. + +## rules + +1. Map AWS compute services to Azure equivalents using this decision matrix: Lambda + API Gateway maps to Azure Functions (Consumption or Premium), ECS/Fargate maps to Azure Container Apps, EC2 maps to Azure App Service (or Azure VMs for lift-and-shift). Choose based on existing architecture and workload characteristics. [learn.microsoft.com -- Azure Functions](https://learn.microsoft.com/en-us/azure/azure-functions/functions-overview) +2. Teams bots require an HTTPS endpoint at `/api/messages` that accepts POST requests from the Bot Framework. Azure App Service and Container Apps provide this natively; Azure Functions requires an HTTP-triggered function bound to that route. [learn.microsoft.com -- Bot messaging endpoint](https://learn.microsoft.com/en-us/azure/bot-service/bot-builder-basics) +3. Teams expects bot responses within 3 seconds for synchronous invoke activities (card actions, dialogs, message extensions). Azure Functions Consumption plan cold starts (5-10 seconds for Node.js) will violate this. Use the Premium plan (pre-warmed instances) or App Service (always-on) for production Teams bots. [learn.microsoft.com -- Functions Premium](https://learn.microsoft.com/en-us/azure/azure-functions/functions-premium-plan) +4. Enable "Always On" for Azure App Service deployments to prevent the app from unloading after idle periods. Without it, the first request after idle triggers a cold start that can cause Teams timeouts. Set this in Configuration > General Settings or via CLI. [learn.microsoft.com -- App Service Always On](https://learn.microsoft.com/en-us/azure/app-service/configure-common) +5. Use Node.js 20 LTS or later as the runtime stack. Set this explicitly in App Service (Configuration > General Settings > Stack: Node, Version: 20-lts) or in the Azure Functions `host.json` and app settings. The Teams AI Library v2 requires Node 20+. [learn.microsoft.com -- Node.js on App Service](https://learn.microsoft.com/en-us/azure/app-service/configure-language-nodejs) +6. Migrate environment variables from AWS Lambda environment / SSM to Azure App Settings. App Settings are injected as `process.env` variables at runtime, equivalent to Lambda environment variables. Use deployment slots for staging/production separation. [learn.microsoft.com -- App Settings](https://learn.microsoft.com/en-us/azure/app-service/configure-common#configure-app-settings) +7. Configure health check endpoints for all Azure compute targets. App Service supports built-in health checks (Configuration > Health check path: `/api/health`). Container Apps use liveness and readiness probes. This replaces Lambda/ECS health monitoring. [learn.microsoft.com -- Health checks](https://learn.microsoft.com/en-us/azure/app-service/monitor-instances-health-check) +8. For streaming and WebSocket scenarios (e.g., AI streaming responses via `stream.emit()`), use App Service or Container Apps with WebSocket support enabled. Azure Functions Consumption plan does not support WebSockets. Enable WebSockets in App Service under Configuration > General Settings. [learn.microsoft.com -- WebSockets](https://learn.microsoft.com/en-us/azure/app-service/configure-common#configure-general-settings) +9. Use deployment slots in App Service for zero-downtime deployments, replacing blue/green patterns built with Lambda aliases/versions or ECS rolling updates. Swap staging to production after validation. [learn.microsoft.com -- Deployment slots](https://learn.microsoft.com/en-us/azure/app-service/deploy-staging-slots) +10. For complex multi-container deployments (previously ECS task definitions with sidecars), use Azure Container Apps with multiple containers per revision, or Azure Kubernetes Service for full orchestration control. Container Apps supports scale-to-zero similar to Fargate Spot. [learn.microsoft.com -- Container Apps](https://learn.microsoft.com/en-us/azure/container-apps/overview) +11. **Azure Functions Premium "Always Ready" instances eliminate cold starts.** Set `WEBSITE_MAX_DYNAMIC_APPLICATION_SCALE_OUT` to cap horizontal scaling, and configure `alwaysReady` in the Premium plan to keep N instances warm. This is the serverless equivalent of ECS minimum task count. Required for Teams bots that must respond to invoke activities within 3 seconds. [learn.microsoft.com -- Functions Premium Always Ready](https://learn.microsoft.com/en-us/azure/azure-functions/functions-premium-plan#always-ready-instances) +12. **Container Apps with Dapr sidecars replaces ECS multi-container patterns.** ECS task definitions with multiple containers (app + sidecar) map to Container Apps revisions with Dapr enabled. Dapr provides service-to-service invocation, state management, pub/sub, and secrets — replacing custom service mesh code. Service discovery uses Dapr app IDs instead of ECS service discovery or Cloud Map. [learn.microsoft.com -- Dapr on Container Apps](https://learn.microsoft.com/en-us/azure/container-apps/dapr-overview) + +## patterns + +### AWS Lambda to Azure App Service deployment + +```shell +# Create resource group and App Service plan +az group create --name my-bot-rg --location eastus +az appservice plan create \ + --name my-bot-plan \ + --resource-group my-bot-rg \ + --sku B1 \ + --is-linux + +# Create the web app with Node.js 20 +az webapp create \ + --name my-teams-bot \ + --resource-group my-bot-rg \ + --plan my-bot-plan \ + --runtime "NODE:20-lts" + +# Enable Always On and WebSockets +az webapp config set \ + --name my-teams-bot \ + --resource-group my-bot-rg \ + --always-on true \ + --web-sockets-enabled true + +# Set application settings (replaces Lambda env vars) +az webapp config appsettings set \ + --name my-teams-bot \ + --resource-group my-bot-rg \ + --settings \ + CLIENT_ID="your-client-id" \ + CLIENT_SECRET="your-client-secret" \ + TENANT_ID="your-tenant-id" \ + OPENAI_API_KEY="your-openai-key" \ + PORT="8080" \ + NODE_ENV="production" + +# Deploy from zip (build locally first: npm run build && zip -r dist.zip .) +az webapp deployment source config-zip \ + --name my-teams-bot \ + --resource-group my-bot-rg \ + --src ./dist.zip + +# Configure health check +az webapp config set \ + --name my-teams-bot \ + --resource-group my-bot-rg \ + --generic-configurations '{"healthCheckPath": "/api/health"}' +``` + +### Azure Functions HTTP trigger for Teams bot endpoint + +```typescript +// src/functions/messages.ts +import { app as azFunc, HttpRequest, HttpResponseInit, InvocationContext } from "@azure/functions"; +import { App } from "@microsoft/teams.apps"; +import { ConsoleLogger } from "@microsoft/teams.common"; + +// Initialize the Teams app once (reused across invocations) +const teamsApp = new App({ + clientId: process.env.CLIENT_ID, + clientSecret: process.env.CLIENT_SECRET, + tenantId: process.env.TENANT_ID, + logger: new ConsoleLogger("my-bot", { level: "info" }), +}); + +teamsApp.on("message", async ({ send, activity }) => { + await send(`You said: "${activity.text}"`); +}); + +// Azure Functions HTTP trigger bound to /api/messages +azFunc.http("messages", { + methods: ["POST"], + authLevel: "anonymous", + route: "api/messages", + handler: async (req: HttpRequest, context: InvocationContext): Promise => { + try { + const body = await req.json(); + // Forward the request to the Teams app for processing + // In practice, use the adapter pattern from @microsoft/teams.apps + // to bridge Azure Functions HTTP to the Teams app's Express handler + return { status: 200, jsonBody: { status: "ok" } }; + } catch (error) { + context.error("Error processing message:", error); + return { status: 500, jsonBody: { error: "Internal server error" } }; + } + }, +}); +``` + +### Container Apps deployment for ECS/Fargate migration + +```shell +# Create Container Apps environment (replaces ECS cluster) +az containerapp env create \ + --name my-bot-env \ + --resource-group my-bot-rg \ + --location eastus + +# Deploy container (replaces ECS task definition + service) +az containerapp create \ + --name my-teams-bot \ + --resource-group my-bot-rg \ + --environment my-bot-env \ + --image myregistry.azurecr.io/my-teams-bot:latest \ + --target-port 3978 \ + --ingress external \ + --min-replicas 1 \ + --max-replicas 10 \ + --cpu 0.5 \ + --memory 1.0Gi \ + --env-vars \ + CLIENT_ID="your-client-id" \ + CLIENT_SECRET=secretref:client-secret \ + TENANT_ID="your-tenant-id" \ + NODE_ENV="production" + +# Configure scaling rule based on HTTP concurrent requests +az containerapp update \ + --name my-teams-bot \ + --resource-group my-bot-rg \ + --scale-rule-name http-rule \ + --scale-rule-type http \ + --scale-rule-http-concurrency 50 +``` + +### Container Apps with Dapr sidecar (ECS multi-container migration) + +```shell +# Create a Dapr-enabled Container App (replaces ECS task with sidecar containers) +az containerapp create \ + --name my-teams-bot \ + --resource-group my-bot-rg \ + --environment my-bot-env \ + --image myregistry.azurecr.io/my-teams-bot:latest \ + --target-port 3978 \ + --ingress external \ + --min-replicas 1 \ + --max-replicas 10 \ + --enable-dapr \ + --dapr-app-id my-teams-bot \ + --dapr-app-port 3978 \ + --dapr-app-protocol http +``` + +**TypeScript: invoking another service via Dapr (replaces ECS service discovery):** + +```typescript +import { DaprClient, HttpMethod } from "@dapr/dapr"; + +const dapr = new DaprClient(); + +// Invoke another Container App by its Dapr app ID +// Replaces: http://service-name.local:3000/api/data (ECS service discovery) +async function callDataService(query: string) { + const response = await dapr.invoker.invoke( + "data-service", // Dapr app ID (replaces ECS service name) + `api/search?q=${query}`, // method/path + HttpMethod.GET + ); + return response; +} +``` + +## pitfalls + +- **Azure Functions Consumption cold starts**: Node.js cold starts on the Consumption plan can take 5-10 seconds. Teams invoke activities (card actions, dialogs) time out at 3 seconds. Either use the Premium plan with at least one pre-warmed instance, or use App Service with Always On enabled. +- **Forgetting Always On**: App Service without Always On unloads the app after ~20 minutes idle. The next incoming Teams message triggers a full restart, causing timeout errors. Always enable Always On for bot workloads. +- **Port mismatch**: Azure App Service expects the app to listen on `process.env.PORT` (defaults to `8080`), not the Teams default of `3978`. Set `PORT` in App Settings or update `app.start(process.env.PORT || 8080)` for App Service deployments. +- **Missing /api/messages route**: The Azure Bot registration messaging endpoint must point to `https://your-app.azurewebsites.net/api/messages`. If the Teams app listens on a different path, update the Bot registration accordingly. +- **Lambda-style single-invocation patterns**: AWS Lambda processes one request per invocation. Azure App Service and Container Apps are long-running processes. Remove any Lambda-specific initialization/teardown patterns (handler export patterns, context.callbackWaitsForEmptyEventLoop) and use the standard `app.start()` pattern. +- **Deployment slot swap without warming**: Swapping a cold staging slot to production causes the same cold-start problem. Use slot warm-up rules or send traffic to staging before swapping. +- **Container Apps scale-to-zero**: If min-replicas is 0, the first request after scale-down has a cold start. Set `--min-replicas 1` for production Teams bots to ensure instant responses. +- **Functions Premium Always Ready is not free-tier**: Always Ready instances incur charges even when idle. Budget for at least 1 always-ready instance per production function app. Without it, the Premium plan still has occasional cold starts during scale-out events. +- **Dapr sidecar port conflict**: Dapr's default HTTP port is 3500 and gRPC is 50001. Ensure your app does not bind to these ports. The `--dapr-app-port` flag tells Dapr which port YOUR app listens on — this must match your Express/Teams `app.start()` port. + +## references + +- [Azure App Service overview](https://learn.microsoft.com/en-us/azure/app-service/overview) +- [Azure Functions Node.js developer guide](https://learn.microsoft.com/en-us/azure/azure-functions/functions-reference-node) +- [Azure Container Apps overview](https://learn.microsoft.com/en-us/azure/container-apps/overview) +- [Azure Functions Premium plan](https://learn.microsoft.com/en-us/azure/azure-functions/functions-premium-plan) +- [Deploy a bot to Azure](https://learn.microsoft.com/en-us/azure/bot-service/bot-builder-deploy-az-cli) +- [Azure App Service deployment slots](https://learn.microsoft.com/en-us/azure/app-service/deploy-staging-slots) +- [Configure Node.js apps for App Service](https://learn.microsoft.com/en-us/azure/app-service/configure-language-nodejs) +- [AWS to Azure services comparison](https://learn.microsoft.com/en-us/azure/architecture/aws-professional/services) + +## instructions + +This expert bridges compute infrastructure between AWS and Azure for cross-platform bot hosting. Use it when adding cross-platform support in either direction and you need to: + +- Map compute services between clouds (Lambda ↔ Azure Functions, ECS ↔ Container Apps, EC2 ↔ App Service) +- Configure Azure App Service for a Teams bot with proper Always On, WebSocket, and Node.js runtime settings +- Set up Azure Functions as a Teams bot endpoint while avoiding cold-start pitfalls +- Deploy containerized bots to Azure Container Apps as a replacement for ECS/Fargate +- Bridge environment variables and deployment configurations between AWS and Azure +- Configure health checks, scaling rules, and deployment slots for production bot hosting + +For Azure → AWS (less common): reverse the mappings. App Service maps to EC2 or Elastic Beanstalk, Azure Functions maps to Lambda + API Gateway, Container Apps maps to ECS/Fargate. + +Pair with `infra-secrets-config-ts.md` for App Settings and environment variable configuration, and `../teams/dev.debug-test-ts.md` for local development setup. + +## research + +Deep Research prompt: + +"Write a micro expert for bridging bot compute between AWS and Azure. Provide a bidirectional decision matrix mapping AWS Lambda+API Gateway ↔ Azure Functions, ECS/Fargate ↔ Container Apps, and EC2 ↔ App Service. Include Node/TS hosting patterns, ingress/routing, env var configuration, scaling differences, cold start mitigation for Teams 3-second response requirements, and bot endpoint considerations. Include deployment CLI examples for both directions." diff --git a/plugins/microsoft-365-agents-toolkit/skills/teams-app-developer/experts/bridge/infra-observability-ts.md b/plugins/microsoft-365-agents-toolkit/skills/teams-app-developer/experts/bridge/infra-observability-ts.md new file mode 100644 index 0000000..7b1ee6e --- /dev/null +++ b/plugins/microsoft-365-agents-toolkit/skills/teams-app-developer/experts/bridge/infra-observability-ts.md @@ -0,0 +1,256 @@ +# infra-observability-ts + +## purpose + +Bridges AWS and Azure observability for cross-platform bot monitoring. Covers CloudWatch to Azure Monitor/Application Insights/Log Analytics (and the reverse). The common direction is AWS → Azure, but the service mappings apply bidirectionally. + +> **Note:** AWS → Azure is the most common direction for this expert. For Azure → AWS, reverse the mappings: Application Insights → CloudWatch + X-Ray, Log Analytics (KQL) → CloudWatch Logs Insights, Azure Monitor Alerts → CloudWatch Alarms + SNS. + +## rules + +1. Map CloudWatch Logs to Application Insights and Log Analytics. Application Insights provides structured telemetry (requests, dependencies, exceptions, traces) while Log Analytics is the query engine (KQL) for exploring that data. Both replace CloudWatch Logs Insights. [learn.microsoft.com -- Application Insights overview](https://learn.microsoft.com/en-us/azure/azure-monitor/app/app-insights-overview) +2. Instrument Node.js Teams bots with the `applicationinsights` npm package. Call `setup()` and `start()` before any other imports to enable automatic dependency tracking, request correlation, and exception capture. This replaces AWS X-Ray SDK instrumentation. [learn.microsoft.com -- Node.js Application Insights](https://learn.microsoft.com/en-us/azure/azure-monitor/app/nodejs) +3. Map CloudWatch Metrics to Azure Monitor Metrics. Custom metrics sent via `trackMetric()` in Application Insights appear in Azure Monitor Metrics Explorer, replacing CloudWatch custom metrics and `putMetricData` calls. [learn.microsoft.com -- Custom metrics](https://learn.microsoft.com/en-us/azure/azure-monitor/app/api-custom-events-metrics) +4. Map CloudWatch Alarms to Azure Monitor Alerts. Create alert rules on Application Insights metrics (response time, failure rate, exception count) or log-based alerts using KQL queries. This replaces CloudWatch Alarm + SNS notification patterns. [learn.microsoft.com -- Azure Monitor Alerts](https://learn.microsoft.com/en-us/azure/azure-monitor/alerts/alerts-overview) +5. Map AWS X-Ray distributed tracing to Application Insights distributed tracing. Application Insights automatically correlates requests across services using operation IDs. The `applicationinsights` SDK propagates trace context headers (`traceparent`) automatically. [learn.microsoft.com -- Distributed tracing](https://learn.microsoft.com/en-us/azure/azure-monitor/app/distributed-trace-data) +6. Integrate with the Teams SDK `ConsoleLogger` by creating a custom logger implementation that forwards to Application Insights. Use `trackTrace()` for log messages, `trackException()` for errors, and `trackEvent()` for business events (bot installs, card actions). [learn.microsoft.com -- Application Insights API](https://learn.microsoft.com/en-us/azure/azure-monitor/app/api-custom-events-metrics) +7. Set the Application Insights connection string via the `APPLICATIONINSIGHTS_CONNECTION_STRING` environment variable in App Settings. Do not hardcode connection strings. App Service and Functions have built-in Application Insights integration that can be enabled without code changes for basic telemetry. [learn.microsoft.com -- Connection strings](https://learn.microsoft.com/en-us/azure/azure-monitor/app/sdk-connection-string) +8. Use KQL queries in Log Analytics to diagnose bot issues, replacing CloudWatch Logs Insights queries. Query `requests`, `dependencies`, `exceptions`, and `traces` tables. Pin frequently used queries to Azure dashboards for team visibility. [learn.microsoft.com -- KQL overview](https://learn.microsoft.com/en-us/azure/data-explorer/kusto/query/) +9. Configure sampling to control telemetry volume and cost. Application Insights supports adaptive sampling (automatic) and fixed-rate sampling. For production bots with high message volume, set a sampling percentage to avoid excessive costs while retaining representative data. [learn.microsoft.com -- Sampling](https://learn.microsoft.com/en-us/azure/azure-monitor/app/sampling-classic-api) +10. Build Azure dashboards for bot health monitoring, replacing CloudWatch Dashboards. Include panels for request rate, response time (P50/P95/P99), failure rate, active conversations, and AI model latency. Use Application Insights workbooks for detailed investigation views. [learn.microsoft.com -- Dashboards](https://learn.microsoft.com/en-us/azure/azure-monitor/app/overview-dashboard) + +## patterns + +### Application Insights setup for a Teams bot + +```typescript +// src/instrumentation.ts — MUST be imported before all other modules +import * as appInsights from "applicationinsights"; + +appInsights + .setup(process.env.APPLICATIONINSIGHTS_CONNECTION_STRING) + .setAutoCollectRequests(true) + .setAutoCollectPerformance(true) + .setAutoCollectExceptions(true) + .setAutoCollectDependencies(true) + .setAutoCollectConsole(true, true) // capture console.log and console.error + .setDistributedTracingMode(appInsights.DistributedTracingModes.AI_AND_W3C) + .setSendLiveMetrics(true) + .start(); + +export const telemetryClient = appInsights.defaultClient; +``` + +```typescript +// src/index.ts +import { telemetryClient } from "./instrumentation.js"; // import first! +import { App } from "@microsoft/teams.apps"; +import { ConsoleLogger } from "@microsoft/teams.common"; + +const app = new App({ + clientId: process.env.CLIENT_ID, + clientSecret: process.env.CLIENT_SECRET, + tenantId: process.env.TENANT_ID, + logger: new ConsoleLogger("my-bot", { level: "info" }), +}); + +// Track custom events for bot lifecycle +app.on("install.add", async ({ send, activity }) => { + telemetryClient.trackEvent({ + name: "BotInstalled", + properties: { + conversationType: activity.conversation.conversationType ?? "personal", + tenantId: activity.conversation.tenantId ?? "unknown", + }, + }); + await send("Hello! I am now installed."); +}); + +// Track AI prompt latency as a custom metric +app.on("message", async ({ send, activity }) => { + const start = Date.now(); + // ... process with AI prompt ... + const duration = Date.now() - start; + + telemetryClient.trackMetric({ + name: "AIPromptLatency", + value: duration, + properties: { conversationId: activity.conversation.id }, + }); +}); + +// Track unhandled errors +app.event("error", ({ error }) => { + telemetryClient.trackException({ exception: error as Error }); +}); + +app.start(process.env.PORT || 3978); +``` + +### KQL queries for bot diagnostics + +```text +// Request latency for the /api/messages endpoint (replaces CloudWatch Logs Insights) +requests +| where name == "POST /api/messages" +| where timestamp > ago(24h) +| summarize + avg(duration), + percentile(duration, 50), + percentile(duration, 95), + percentile(duration, 99), + count() + by bin(timestamp, 5m) +| render timechart + +// Failed requests with exception details +requests +| where success == false +| where timestamp > ago(1h) +| join kind=inner ( + exceptions + | where timestamp > ago(1h) + ) on operation_Id +| project timestamp, name, resultCode, duration, exceptionType = type, exceptionMessage = outerMessage +| order by timestamp desc +| take 50 + +// Bot install/uninstall events over time +customEvents +| where name in ("BotInstalled", "BotUninstalled") +| where timestamp > ago(7d) +| summarize count() by name, bin(timestamp, 1d) +| render columnchart + +// AI prompt latency distribution +customMetrics +| where name == "AIPromptLatency" +| where timestamp > ago(24h) +| summarize avg(value), percentile(value, 95), max(value) by bin(timestamp, 15m) +| render timechart + +// Dependency call failures (external APIs, databases) +dependencies +| where success == false +| where timestamp > ago(6h) +| summarize failureCount = count() by target, name, resultCode +| order by failureCount desc +``` + +### Custom logger that bridges ConsoleLogger to Application Insights + +```typescript +// src/logger.ts +import * as appInsights from "applicationinsights"; +import { ILogger } from "@microsoft/teams.common"; + +export class AppInsightsLogger implements ILogger { + private client: appInsights.TelemetryClient; + private name: string; + + constructor(name: string, client?: appInsights.TelemetryClient) { + this.name = name; + this.client = client ?? appInsights.defaultClient; + } + + error(message: string, ...args: unknown[]): void { + const formatted = this.format(message, args); + console.error(`[${this.name}] ${formatted}`); + this.client.trackTrace({ + message: formatted, + severity: appInsights.Contracts.SeverityLevel.Error, + properties: { component: this.name }, + }); + } + + warn(message: string, ...args: unknown[]): void { + const formatted = this.format(message, args); + console.warn(`[${this.name}] ${formatted}`); + this.client.trackTrace({ + message: formatted, + severity: appInsights.Contracts.SeverityLevel.Warning, + properties: { component: this.name }, + }); + } + + info(message: string, ...args: unknown[]): void { + const formatted = this.format(message, args); + console.info(`[${this.name}] ${formatted}`); + this.client.trackTrace({ + message: formatted, + severity: appInsights.Contracts.SeverityLevel.Information, + properties: { component: this.name }, + }); + } + + debug(message: string, ...args: unknown[]): void { + const formatted = this.format(message, args); + console.debug(`[${this.name}] ${formatted}`); + this.client.trackTrace({ + message: formatted, + severity: appInsights.Contracts.SeverityLevel.Verbose, + properties: { component: this.name }, + }); + } + + log(message: string, ...args: unknown[]): void { + this.info(message, ...args); + } + + child(name: string): ILogger { + return new AppInsightsLogger(`${this.name}/${name}`, this.client); + } + + private format(message: string, args: unknown[]): string { + return args.length > 0 ? `${message} ${args.map(String).join(" ")}` : message; + } +} + +// Usage in src/index.ts: +// import { AppInsightsLogger } from "./logger.js"; +// const app = new App({ +// logger: new AppInsightsLogger("my-bot"), +// ... +// }); +``` + +## pitfalls + +- **Late instrumentation import**: The `applicationinsights` setup must run before importing any other modules (especially `http`/`https`). If imported after, automatic dependency tracking and request correlation will not work. Always import the instrumentation module first in your entry point. +- **Missing connection string**: If `APPLICATIONINSIGHTS_CONNECTION_STRING` is not set, the SDK initializes silently in no-op mode. Telemetry is lost without any error. Always verify the connection string is configured in App Settings. +- **CloudWatch Logs Insights queries not portable**: CloudWatch Logs Insights query syntax is completely different from KQL. All existing dashboard queries must be manually rewritten in KQL. The table structures also differ (e.g., `@timestamp` becomes `timestamp`, `@message` becomes `message`). +- **Cost surprise from high-volume bots**: Application Insights charges per GB of ingested telemetry. A high-traffic bot logging every message can generate significant costs. Configure sampling early and exclude verbose trace levels in production. +- **Console.log not structured**: Raw `console.log` statements captured by Application Insights appear as unstructured trace messages. Use `trackEvent()`, `trackMetric()`, and `trackTrace()` with properties for queryable, structured telemetry. +- **X-Ray annotations not migrated**: AWS X-Ray annotations and metadata have no automatic migration path to Application Insights custom properties. Manually map important annotations to `trackTrace()` or `trackEvent()` property bags. +- **Forgetting to flush on shutdown**: Application Insights batches telemetry before sending. If the process exits abruptly (e.g., container restart), buffered telemetry is lost. Call `telemetryClient.flush()` in a graceful shutdown handler. + +## references + +- [Application Insights overview](https://learn.microsoft.com/en-us/azure/azure-monitor/app/app-insights-overview) +- [Application Insights for Node.js](https://learn.microsoft.com/en-us/azure/azure-monitor/app/nodejs) +- [Application Insights API reference](https://learn.microsoft.com/en-us/azure/azure-monitor/app/api-custom-events-metrics) +- [KQL quick reference](https://learn.microsoft.com/en-us/azure/data-explorer/kusto/query/kql-quick-reference) +- [Azure Monitor Alerts](https://learn.microsoft.com/en-us/azure/azure-monitor/alerts/alerts-overview) +- [Application Insights sampling](https://learn.microsoft.com/en-us/azure/azure-monitor/app/sampling-classic-api) +- [Distributed tracing in Application Insights](https://learn.microsoft.com/en-us/azure/azure-monitor/app/distributed-trace-data) +- [AWS to Azure services comparison -- Management and monitoring](https://learn.microsoft.com/en-us/azure/architecture/aws-professional/services#management-and-monitoring) + +## instructions + +This expert bridges observability between AWS and Azure for cross-platform bot monitoring. Use it when adding cross-platform support in either direction and you need to: + +- Map monitoring services between clouds (CloudWatch ↔ Azure Monitor, X-Ray ↔ Application Insights, CloudWatch Alarms ↔ Azure Alerts) +- Instrument a Node.js Teams bot with the `applicationinsights` npm package +- Write KQL queries for bot diagnostics (latency, errors, usage patterns) +- Build Azure dashboards for bot health monitoring +- Bridge the Teams SDK `ConsoleLogger` to Application Insights telemetry + +For Azure → AWS (less common): reverse the mappings. Application Insights maps to CloudWatch + X-Ray, KQL maps to CloudWatch Logs Insights, Azure Alerts map to CloudWatch Alarms + SNS. + +Pair with `../teams/dev.debug-test-ts.md` for Teams SDK ConsoleLogger integration, and `infra-compute-ts.md` for Application Insights instrumentation on the target compute platform. + +## research + +Deep Research prompt: + +"Write a micro expert for bridging observability between AWS CloudWatch and Azure Monitor/Application Insights for cross-platform bots. Cover structured logging with the applicationinsights npm package, distributed tracing (X-Ray ↔ Application Insights), KQL ↔ CloudWatch Logs Insights query mapping, custom metrics, alert rules, dashboard setup, and cost management with sampling bidirectionally. Include instrumentation code examples and diagnostic queries." diff --git a/plugins/microsoft-365-agents-toolkit/skills/teams-app-developer/experts/bridge/infra-secrets-config-ts.md b/plugins/microsoft-365-agents-toolkit/skills/teams-app-developer/experts/bridge/infra-secrets-config-ts.md new file mode 100644 index 0000000..fc7ff87 --- /dev/null +++ b/plugins/microsoft-365-agents-toolkit/skills/teams-app-developer/experts/bridge/infra-secrets-config-ts.md @@ -0,0 +1,193 @@ +# infra-secrets-config-ts + +## purpose + +Bridges AWS and Azure secrets/configuration management for cross-platform bot deployments. Covers Secrets Manager/SSM to Key Vault/App Configuration (and the reverse). The common direction is AWS → Azure, but the service mappings apply bidirectionally. + +> **Note:** AWS → Azure is the most common direction for this expert. For Azure → AWS, reverse the mappings: Key Vault → Secrets Manager, App Configuration → SSM Parameter Store, managed identity → IAM roles. + +## rules + +1. Map AWS Secrets Manager to Azure Key Vault for storing sensitive credentials (CLIENT_SECRET, OPENAI_API_KEY, database passwords). Key Vault provides versioning, soft-delete, access policies, and audit logging, similar to Secrets Manager. Use `@azure/keyvault-secrets` for programmatic access. [learn.microsoft.com -- Key Vault overview](https://learn.microsoft.com/en-us/azure/key-vault/general/overview) +2. Map AWS SSM Parameter Store to Azure App Configuration for non-secret configuration values (feature flags, endpoint URLs, tuning parameters). App Configuration supports key-value pairs, labels for environments, and feature management. Use `@azure/app-configuration` for programmatic access. [learn.microsoft.com -- App Configuration overview](https://learn.microsoft.com/en-us/azure/azure-app-configuration/overview) +3. Use managed identity (system-assigned or user-assigned) to access Key Vault from Azure compute, eliminating the need for Key Vault credentials in code. This replaces IAM role-based access patterns used with AWS Secrets Manager. Configure with `@azure/identity` DefaultAzureCredential. [learn.microsoft.com -- Managed identity](https://learn.microsoft.com/en-us/azure/active-directory/managed-identities-azure-resources/overview) +4. For App Service deployments, use Key Vault references in App Settings instead of direct secret values. The syntax `@Microsoft.KeyVault(SecretUri=https://myvault.vault.azure.net/secrets/MySecret/)` resolves secrets at runtime without application code changes. This is the simplest migration path from `.env` files. [learn.microsoft.com -- Key Vault references](https://learn.microsoft.com/en-us/azure/app-service/app-service-key-vault-references) +5. Migrate all Teams bot environment variables from `.env` files to Azure App Settings for production. Required variables: `CLIENT_ID`, `CLIENT_SECRET`, `TENANT_ID`. Common additions: `OPENAI_API_KEY` or `AZURE_OPENAI_*`, `APPLICATIONINSIGHTS_CONNECTION_STRING`, `PORT`. Keep `.env` for local development only. [learn.microsoft.com -- App Settings](https://learn.microsoft.com/en-us/azure/app-service/configure-common#configure-app-settings) +6. Never commit secrets to source control. Add `.env` to `.gitignore`. Use `.env.example` or `.env.template` with placeholder values to document required variables. This applies equally to AWS and Azure workflows. [OWASP -- Secrets in source code](https://owasp.org/www-community/vulnerabilities/Use_of_hard-coded_password) +7. Configure secret rotation for `CLIENT_SECRET` using Key Vault rotation policies or Azure AD app credential rotation. AWS Secrets Manager automatic rotation maps to Key Vault auto-rotation with Event Grid notifications. Plan for multi-credential overlap during rotation windows. [learn.microsoft.com -- Key Vault rotation](https://learn.microsoft.com/en-us/azure/key-vault/secrets/tutorial-rotation) +8. Use the Teams SDK `managedIdentityClientId` option for zero-secret bot authentication in production. Set to `"system"` for system-assigned managed identity or the client ID string for user-assigned identity. This eliminates the need for `CLIENT_SECRET` in production entirely. [github.com/microsoft/teams.ts](https://github.com/microsoft/teams.ts) +9. Apply least-privilege access policies to Key Vault. Grant only "Get" and "List" secret permissions to the bot's managed identity. Do not grant "Set", "Delete", or management permissions to runtime identities. Use separate access policies for deployment pipelines vs. runtime. [learn.microsoft.com -- Key Vault access policy](https://learn.microsoft.com/en-us/azure/key-vault/general/assign-access-policy) +10. For local development, use `DefaultAzureCredential` from `@azure/identity` which chains multiple credential sources: environment variables, managed identity, Azure CLI login, and VS Code credentials. This provides a unified auth pattern that works locally and in production without code changes. [learn.microsoft.com -- DefaultAzureCredential](https://learn.microsoft.com/en-us/azure/developer/javascript/sdk/authentication/credential-chains#use-defaultazurecredential-for-flexibility) + +## patterns + +### Accessing Key Vault secrets from a Teams bot + +```typescript +// src/config.ts +import { DefaultAzureCredential } from "@azure/identity"; +import { SecretClient } from "@azure/keyvault-secrets"; + +interface BotConfig { + clientId: string; + clientSecret: string; + tenantId: string; + openaiApiKey: string; +} + +export async function loadConfig(): Promise { + const vaultUrl = process.env.KEY_VAULT_URL; + + // In production: uses managed identity automatically + // Locally: uses Azure CLI credentials or env vars + if (vaultUrl) { + const credential = new DefaultAzureCredential(); + const client = new SecretClient(vaultUrl, credential); + + const [clientId, clientSecret, tenantId, openaiKey] = await Promise.all([ + client.getSecret("bot-client-id"), + client.getSecret("bot-client-secret"), + client.getSecret("bot-tenant-id"), + client.getSecret("openai-api-key"), + ]); + + return { + clientId: clientId.value!, + clientSecret: clientSecret.value!, + tenantId: tenantId.value!, + openaiApiKey: openaiKey.value!, + }; + } + + // Fallback to environment variables for local development + return { + clientId: process.env.CLIENT_ID ?? "", + clientSecret: process.env.CLIENT_SECRET ?? "", + tenantId: process.env.TENANT_ID ?? "", + openaiApiKey: process.env.OPENAI_API_KEY ?? "", + }; +} + +// src/index.ts +import { App } from "@microsoft/teams.apps"; +import { ConsoleLogger } from "@microsoft/teams.common"; +import { loadConfig } from "./config.js"; + +const config = await loadConfig(); + +const app = new App({ + clientId: config.clientId, + clientSecret: config.clientSecret, + tenantId: config.tenantId, + logger: new ConsoleLogger("my-bot", { level: "info" }), +}); + +app.on("message", async ({ send }) => { + await send("Bot is running with Key Vault secrets!"); +}); + +app.start(process.env.PORT || 3978); +``` + +### App Service Key Vault references (zero-code secret injection) + +```shell +# Create Key Vault +az keyvault create \ + --name my-bot-vault \ + --resource-group my-bot-rg \ + --location eastus + +# Store secrets in Key Vault +az keyvault secret set --vault-name my-bot-vault --name "BotClientId" --value "your-client-id" +az keyvault secret set --vault-name my-bot-vault --name "BotClientSecret" --value "your-client-secret" +az keyvault secret set --vault-name my-bot-vault --name "BotTenantId" --value "your-tenant-id" +az keyvault secret set --vault-name my-bot-vault --name "OpenAiApiKey" --value "your-openai-key" + +# Enable system-assigned managed identity on App Service +az webapp identity assign \ + --name my-teams-bot \ + --resource-group my-bot-rg + +# Grant the managed identity access to Key Vault secrets +PRINCIPAL_ID=$(az webapp identity show --name my-teams-bot --resource-group my-bot-rg --query principalId -o tsv) +az keyvault set-policy \ + --name my-bot-vault \ + --object-id "$PRINCIPAL_ID" \ + --secret-permissions get list + +# Set App Settings with Key Vault references (no secrets in App Settings!) +az webapp config appsettings set \ + --name my-teams-bot \ + --resource-group my-bot-rg \ + --settings \ + CLIENT_ID="@Microsoft.KeyVault(SecretUri=https://my-bot-vault.vault.azure.net/secrets/BotClientId/)" \ + CLIENT_SECRET="@Microsoft.KeyVault(SecretUri=https://my-bot-vault.vault.azure.net/secrets/BotClientSecret/)" \ + TENANT_ID="@Microsoft.KeyVault(SecretUri=https://my-bot-vault.vault.azure.net/secrets/BotTenantId/)" \ + OPENAI_API_KEY="@Microsoft.KeyVault(SecretUri=https://my-bot-vault.vault.azure.net/secrets/OpenAiApiKey/)" +``` + +### Managed identity bot configuration (zero-secret production) + +```typescript +// src/index.ts — Production: no CLIENT_SECRET needed at all +import { App } from "@microsoft/teams.apps"; +import { ConsoleLogger } from "@microsoft/teams.common"; + +const app = new App({ + clientId: process.env.CLIENT_ID, + tenantId: process.env.TENANT_ID, + // Use managed identity instead of CLIENT_SECRET + // "system" for system-assigned, or a specific client ID for user-assigned + managedIdentityClientId: process.env.MANAGED_IDENTITY_CLIENT_ID ?? "system", + logger: new ConsoleLogger("my-bot", { level: "info" }), +}); + +app.on("message", async ({ send }) => { + await send("Running with managed identity - no secrets in config!"); +}); + +app.start(process.env.PORT || 3978); +``` + +## pitfalls + +- **Key Vault references showing raw `@Microsoft.KeyVault(...)` string**: If the App Service cannot resolve Key Vault references, the raw reference string is used as the value instead of the secret. This happens when managed identity lacks "Get" permission on the vault or when the secret URI is malformed. Check the App Service "Configuration" blade for a green checkmark next to each reference. +- **DefaultAzureCredential slow locally**: `DefaultAzureCredential` tries multiple credential sources in sequence. If early sources timeout (e.g., managed identity endpoint on a dev machine), it can take 10+ seconds. For local development, use `AzureCliCredential` directly or set `AZURE_TENANT_ID`, `AZURE_CLIENT_ID`, `AZURE_CLIENT_SECRET` environment variables. +- **Forgetting to restart after App Settings change**: Azure App Service caches environment variables at startup. After updating App Settings or Key Vault references, restart the App Service to pick up the new values. +- **SSM Parameter Store hierarchical paths not mapped**: AWS SSM supports hierarchical parameter paths (`/myapp/prod/db-password`). Azure App Configuration uses flat key-value pairs with optional labels. Flatten the hierarchy or use labels (`key=db-password, label=prod`) during migration. +- **Secret rotation breaking the bot**: When rotating `CLIENT_SECRET` in Azure AD, both the old and new credentials must be valid simultaneously during the transition. Add the new credential first, update Key Vault, then remove the old credential after confirming the bot works. +- **Mixing .env and App Settings**: In production, App Settings override `.env` values. If both are present with different values, the App Settings value wins. Remove `.env` from deployment packages to avoid confusion. +- **Key Vault soft-delete blocking recreation**: Key Vault has soft-delete enabled by default. If you delete and recreate a vault with the same name, the operation fails. Purge the soft-deleted vault first or use a different name. + +## references + +- [Azure Key Vault overview](https://learn.microsoft.com/en-us/azure/key-vault/general/overview) +- [Azure App Configuration overview](https://learn.microsoft.com/en-us/azure/azure-app-configuration/overview) +- [Key Vault references for App Service](https://learn.microsoft.com/en-us/azure/app-service/app-service-key-vault-references) +- [Managed identities overview](https://learn.microsoft.com/en-us/azure/active-directory/managed-identities-azure-resources/overview) +- [@azure/identity -- DefaultAzureCredential](https://learn.microsoft.com/en-us/azure/developer/javascript/sdk/authentication/credential-chains) +- [@azure/keyvault-secrets npm](https://www.npmjs.com/package/@azure/keyvault-secrets) +- [Key Vault secret rotation tutorial](https://learn.microsoft.com/en-us/azure/key-vault/secrets/tutorial-rotation) +- [AWS to Azure services comparison -- Security](https://learn.microsoft.com/en-us/azure/architecture/aws-professional/services#security-identity-and-access) + +## instructions + +This expert bridges secrets and configuration management between AWS and Azure for cross-platform bot hosting. Use it when adding cross-platform support in either direction and you need to: + +- Map secrets services between clouds (Secrets Manager ↔ Key Vault, SSM ↔ App Configuration) +- Set up Key Vault references in App Service App Settings for zero-code secret injection +- Configure managed identity for passwordless access to Key Vault and other Azure services +- Bridge `.env` files to production-ready App Settings on either cloud +- Implement the `managedIdentityClientId` option in the Teams SDK for zero-secret bot authentication +- Plan secret rotation for CLIENT_SECRET and other credentials + +For Azure → AWS (less common): reverse the mappings. Key Vault maps to Secrets Manager, App Configuration maps to SSM Parameter Store, managed identity maps to IAM roles. + +Pair with `../security/secrets-ts.md` for general secrets management best practices, and `../teams/runtime.app-init-ts.md` for the Teams bot credentials that need to be stored. + +## research + +Deep Research prompt: + +"Write a micro expert for bridging secrets/config between AWS and Azure for cross-platform bots. Cover Secrets Manager ↔ Key Vault mapping, SSM ↔ App Configuration, Key Vault references in App Service, managed identity ↔ IAM roles, @azure/keyvault-secrets and @azure/identity SDK usage, .env to App Settings migration, and secret rotation patterns bidirectionally. Include code examples and CLI commands." diff --git a/plugins/microsoft-365-agents-toolkit/skills/teams-app-developer/experts/bridge/infra-storage-ts.md b/plugins/microsoft-365-agents-toolkit/skills/teams-app-developer/experts/bridge/infra-storage-ts.md new file mode 100644 index 0000000..256bd7e --- /dev/null +++ b/plugins/microsoft-365-agents-toolkit/skills/teams-app-developer/experts/bridge/infra-storage-ts.md @@ -0,0 +1,248 @@ +# infra-storage-ts + +## purpose + +Bridges AWS and Azure data storage for cross-platform bot state and application data. Covers S3/DynamoDB/RDS to Azure Blob Storage/Cosmos DB/Azure SQL (and the reverse). The common direction is AWS → Azure, but the service mappings apply bidirectionally. + +> **Note:** AWS → Azure is the most common direction for this expert. For Azure → AWS, reverse the mappings: Blob Storage → S3, Cosmos DB → DynamoDB, Azure SQL → RDS. + +## rules + +1. Map AWS S3 to Azure Blob Storage for file and object storage. Both provide tiered storage (Hot/Cool/Archive maps to S3 Standard/IA/Glacier), versioning, and lifecycle policies. Use `@azure/storage-blob` for programmatic access. Container names in Blob Storage are equivalent to S3 buckets. [learn.microsoft.com -- Blob Storage overview](https://learn.microsoft.com/en-us/azure/storage/blobs/storage-blobs-overview) +2. Map AWS DynamoDB to Azure Cosmos DB for NoSQL key-value and document storage. Cosmos DB offers multiple APIs: Core SQL (recommended for new development), Table API (closest DynamoDB migration path), and MongoDB API. Choose based on query complexity and migration effort. [learn.microsoft.com -- Cosmos DB overview](https://learn.microsoft.com/en-us/azure/cosmos-db/introduction) +3. Map AWS RDS (MySQL/PostgreSQL/SQL Server) to the equivalent Azure managed database: RDS MySQL maps to Azure Database for MySQL, RDS PostgreSQL maps to Azure Database for PostgreSQL, RDS SQL Server maps to Azure SQL Database. Schema and data can be migrated with Azure Database Migration Service. [learn.microsoft.com -- Azure SQL overview](https://learn.microsoft.com/en-us/azure/azure-sql/database/sql-database-paas-overview) +4. Implement the Teams SDK `IStorage` interface for bot state management with Cosmos DB. The `IStorage` interface requires `get(key)`, `set(key, value)`, and `delete(key)` methods. This replaces any custom DynamoDB state store used by a Slack Bolt bot. [github.com/microsoft/teams.ts](https://github.com/microsoft/teams.ts) +5. For simple bot state (conversation history, user preferences), use Cosmos DB Core SQL API with a single container partitioned by the state key. This provides single-digit millisecond reads, automatic indexing, and serverless pricing for low-traffic bots. [learn.microsoft.com -- Cosmos DB serverless](https://learn.microsoft.com/en-us/azure/cosmos-db/serverless) +6. Use managed identity or connection strings stored in Key Vault for database access. Never hardcode connection strings in source code. For Cosmos DB, use `@azure/cosmos` with `DefaultAzureCredential` for managed identity access, or store the connection string in Key Vault. [learn.microsoft.com -- Cosmos DB RBAC](https://learn.microsoft.com/en-us/azure/cosmos-db/how-to-setup-rbac) +7. For DynamoDB to Cosmos DB Table API migration, use the Azure Cosmos DB Data Migration Tool or custom scripts. Table API preserves the key-value access pattern (PartitionKey + RowKey), making it the lowest-effort migration path. However, Core SQL API offers richer querying capabilities for future needs. [learn.microsoft.com -- Cosmos DB Table API](https://learn.microsoft.com/en-us/azure/cosmos-db/table/introduction) +8. Configure Cosmos DB request units (RUs) appropriately. DynamoDB uses read/write capacity units (RCUs/WCUs); Cosmos DB uses RUs. A simple bot state read costs approximately 1 RU. Start with serverless mode (pay-per-request) for development and low traffic, switch to provisioned throughput for predictable workloads. [learn.microsoft.com -- Request units](https://learn.microsoft.com/en-us/azure/cosmos-db/request-units) +9. Plan data migration strategy: for S3 to Blob Storage, use AzCopy or Azure Data Factory for bulk migration. For DynamoDB to Cosmos DB, export to JSON from DynamoDB and import with the Cosmos DB Data Migration Tool. For RDS, use Azure Database Migration Service for online migration with minimal downtime. [learn.microsoft.com -- AzCopy](https://learn.microsoft.com/en-us/azure/storage/common/storage-use-azcopy-v10) +10. Implement retry logic and handle throttling for Cosmos DB operations. Unlike DynamoDB which returns `ProvisionedThroughputExceededException`, Cosmos DB returns HTTP 429 with a `x-ms-retry-after-ms` header. The `@azure/cosmos` SDK has built-in retry logic, but configure `maxRetryCount` and `retryAfterInMs` for your workload. [learn.microsoft.com -- Cosmos DB best practices](https://learn.microsoft.com/en-us/azure/cosmos-db/nosql/best-practice-dotnet) + +## patterns + +### IStorage implementation with Cosmos DB for bot state + +```typescript +// src/storage/cosmos-storage.ts +import { CosmosClient, Container, Database } from "@azure/cosmos"; +import { IStorage } from "@microsoft/teams.common"; + +export class CosmosDbStorage implements IStorage { + private container: Container; + private initialized = false; + + constructor( + private cosmosClient: CosmosClient, + private databaseId: string, + private containerId: string, + ) { + this.container = this.cosmosClient + .database(this.databaseId) + .container(this.containerId); + } + + async initialize(): Promise { + if (this.initialized) return; + + // Create database and container if they don't exist + const { database } = await this.cosmosClient.databases.createIfNotExists({ + id: this.databaseId, + }); + await database.containers.createIfNotExists({ + id: this.containerId, + partitionKey: { paths: ["/id"] }, + }); + + this.container = this.cosmosClient + .database(this.databaseId) + .container(this.containerId); + this.initialized = true; + } + + async get(key: string): Promise { + await this.initialize(); + try { + const { resource } = await this.container.item(key, key).read(); + if (!resource) return undefined; + // Strip Cosmos DB metadata before returning + const { id, _rid, _self, _etag, _attachments, _ts, ...data } = resource as Record; + return data as T; + } catch (error: unknown) { + if ((error as { code: number }).code === 404) return undefined; + throw error; + } + } + + async set(key: string, value: T): Promise { + await this.initialize(); + await this.container.items.upsert({ id: key, ...value as object }); + } + + async delete(key: string): Promise { + await this.initialize(); + try { + await this.container.item(key, key).delete(); + } catch (error: unknown) { + if ((error as { code: number }).code !== 404) throw error; + } + } +} +``` + +```typescript +// src/index.ts — Using CosmosDbStorage with the Teams app +import { App } from "@microsoft/teams.apps"; +import { CosmosClient } from "@azure/cosmos"; +import { CosmosDbStorage } from "./storage/cosmos-storage.js"; + +const cosmosClient = new CosmosClient(process.env.COSMOS_CONNECTION_STRING!); +const storage = new CosmosDbStorage(cosmosClient, "teams-bot", "state"); + +const app = new App({ + clientId: process.env.CLIENT_ID, + clientSecret: process.env.CLIENT_SECRET, + tenantId: process.env.TENANT_ID, + storage, // Cosmos DB backs all bot state +}); + +app.on("message", async ({ send, activity }) => { + // State is now persisted to Cosmos DB via the IStorage interface + await send(`Echo: ${activity.text}`); +}); + +app.start(process.env.PORT || 3978); +``` + +### S3 to Azure Blob Storage migration and access + +```typescript +// src/storage/blob-client.ts +import { BlobServiceClient, ContainerClient } from "@azure/storage-blob"; +import { DefaultAzureCredential } from "@azure/identity"; + +// Using managed identity (production) +const blobServiceClient = new BlobServiceClient( + `https://${process.env.STORAGE_ACCOUNT_NAME}.blob.core.windows.net`, + new DefaultAzureCredential(), +); + +// Or using connection string (development) +// const blobServiceClient = BlobServiceClient.fromConnectionString( +// process.env.AZURE_STORAGE_CONNECTION_STRING!, +// ); + +export async function uploadFile( + containerName: string, + blobName: string, + content: Buffer, +): Promise { + const containerClient = blobServiceClient.getContainerClient(containerName); + await containerClient.createIfNotExists(); + + const blockBlobClient = containerClient.getBlockBlobClient(blobName); + await blockBlobClient.upload(content, content.length); + return blockBlobClient.url; +} + +export async function downloadFile( + containerName: string, + blobName: string, +): Promise { + const containerClient = blobServiceClient.getContainerClient(containerName); + const blobClient = containerClient.getBlobClient(blobName); + const response = await blobClient.download(); + + const chunks: Buffer[] = []; + for await (const chunk of response.readableStreamBody!) { + chunks.push(Buffer.from(chunk)); + } + return Buffer.concat(chunks); +} + +// Migration command: bulk copy from S3 to Blob Storage +// azcopy copy "https://s3.amazonaws.com/my-bucket" \ +// "https://mystorageaccount.blob.core.windows.net/my-container?SAS_TOKEN" \ +// --recursive +``` + +### Cosmos DB with managed identity (replacing DynamoDB IAM role access) + +```typescript +// src/storage/cosmos-managed.ts +import { CosmosClient } from "@azure/cosmos"; +import { DefaultAzureCredential } from "@azure/identity"; + +// Managed identity access — no connection string needed +// Requires Cosmos DB RBAC role assignment: +// az cosmosdb sql role assignment create \ +// --account-name my-cosmos-db \ +// --resource-group my-bot-rg \ +// --scope "/" \ +// --principal-id \ +// --role-definition-id 00000000-0000-0000-0000-000000000002 # Built-in Data Contributor + +const credential = new DefaultAzureCredential(); + +const cosmosClient = new CosmosClient({ + endpoint: process.env.COSMOS_ENDPOINT!, // https://my-cosmos-db.documents.azure.com:443/ + aadCredentials: credential, +}); + +// Usage is identical to connection-string-based access +const database = cosmosClient.database("teams-bot"); +const container = database.container("state"); + +// Read an item +const { resource } = await container.item("user-123", "user-123").read(); + +// Upsert an item +await container.items.upsert({ + id: "user-123", + messages: [], + preferences: { theme: "dark" }, +}); +``` + +## pitfalls + +- **DynamoDB to Cosmos DB partition key mismatch**: DynamoDB uses a composite key (partition key + sort key). Cosmos DB Core SQL API uses a single partition key with a separate `id` field. Plan the key mapping carefully. If using Table API, PartitionKey + RowKey maps more directly. +- **Cosmos DB RU starvation**: Unlike DynamoDB auto-scaling which adjusts capacity based on traffic, Cosmos DB provisioned throughput has a fixed RU limit. Exceeding it causes 429 errors. Start with serverless mode or configure auto-scale (400-4000 RU/s) to handle traffic bursts. +- **Connection string in source code**: Cosmos DB connection strings contain the master key with full read/write access. Never hardcode them. Use managed identity with RBAC for production, or store connection strings in Key Vault. +- **Forgetting to create the database/container**: Unlike DynamoDB which creates tables on demand (with `CreateTable`), Cosmos DB requires explicit database and container creation. Use `createIfNotExists()` in the storage implementation or create resources via infrastructure-as-code. +- **Blob Storage access tier costs**: S3 to Blob Storage migration may change cost profiles. Blobs default to Hot tier; if the data is rarely accessed (like archived conversation logs), set to Cool or Archive tier to reduce costs. +- **Cosmos DB item size limit**: Cosmos DB items are limited to 2 MB. DynamoDB items are limited to 400 KB. While the Cosmos limit is higher, storing large conversation histories in a single item can approach this limit. Consider splitting long histories across multiple items. +- **Missing index policy tuning**: Cosmos DB indexes all properties by default (unlike DynamoDB where you must explicitly create secondary indexes). This is convenient but increases RU cost for writes. Exclude large text fields from indexing if they are never queried. +- **AzCopy SAS token expiration**: When using AzCopy for S3 to Blob migration, SAS tokens have expiration times. For large migrations that take hours, set a sufficiently long expiration or use managed identity with AzCopy. + +## references + +- [Azure Blob Storage overview](https://learn.microsoft.com/en-us/azure/storage/blobs/storage-blobs-overview) +- [Azure Cosmos DB overview](https://learn.microsoft.com/en-us/azure/cosmos-db/introduction) +- [Azure SQL Database overview](https://learn.microsoft.com/en-us/azure/azure-sql/database/sql-database-paas-overview) +- [Cosmos DB Table API](https://learn.microsoft.com/en-us/azure/cosmos-db/table/introduction) +- [Cosmos DB RBAC with managed identity](https://learn.microsoft.com/en-us/azure/cosmos-db/how-to-setup-rbac) +- [@azure/cosmos npm](https://www.npmjs.com/package/@azure/cosmos) +- [@azure/storage-blob npm](https://www.npmjs.com/package/@azure/storage-blob) +- [AzCopy tool](https://learn.microsoft.com/en-us/azure/storage/common/storage-use-azcopy-v10) +- [AWS to Azure services comparison -- Storage](https://learn.microsoft.com/en-us/azure/architecture/aws-professional/services#storage) + +## instructions + +This expert bridges data storage between AWS and Azure for cross-platform bot hosting. Use it when adding cross-platform support in either direction and you need to: + +- Map storage services between clouds (S3 ↔ Blob Storage, DynamoDB ↔ Cosmos DB, RDS ↔ Azure SQL) +- Implement the Teams SDK `IStorage` interface backed by Cosmos DB for persistent bot state +- Choose between Cosmos DB Core SQL API and Table API for DynamoDB migration +- Set up managed identity access for Cosmos DB and Blob Storage (replacing IAM roles) +- Plan bulk data migration with AzCopy, Data Migration Tool, or Azure Data Factory + +For Azure → AWS (less common): reverse the mappings. Blob Storage maps to S3, Cosmos DB maps to DynamoDB, Azure SQL maps to RDS. + +Pair with `../teams/state.storage-patterns-ts.md` for implementing the Teams SDK IStorage interface with Cosmos DB, and `infra-secrets-config-ts.md` for securing connection strings. + +## research + +Deep Research prompt: + +"Write a micro expert for bridging bot storage between AWS and Azure. Map S3 ↔ Azure Blob Storage, DynamoDB ↔ Cosmos DB (Core SQL vs Table API), and RDS ↔ Azure SQL/PostgreSQL bidirectionally. Include implementing the Teams SDK IStorage interface with Cosmos DB, managed identity access patterns, data migration strategies with AzCopy and Data Migration Tool, partition key mapping, and Node.js client code examples." diff --git a/plugins/microsoft-365-agents-toolkit/skills/teams-app-developer/experts/bridge/interactive-responses-ts.md b/plugins/microsoft-365-agents-toolkit/skills/teams-app-developer/experts/bridge/interactive-responses-ts.md new file mode 100644 index 0000000..a8f49d4 --- /dev/null +++ b/plugins/microsoft-365-agents-toolkit/skills/teams-app-developer/experts/bridge/interactive-responses-ts.md @@ -0,0 +1,383 @@ +# interactive-responses-ts + +## purpose + +Bridges Slack interactive response patterns (respond, replace_original, ephemeral) and Teams card/message update patterns for cross-platform bots targeting Slack, Teams, or both. + +## rules + +1. **Slack `respond({ replace_original: true })` → Teams invoke response with card.** In Slack, `respond()` with `replace_original` replaces the message that triggered the interaction. In Teams, return a new Adaptive Card from the `card.action` handler's return value — the Bot Framework replaces the card inline. The handler must return `{ status: 200, body: { ... } }` with the replacement card. [learn.microsoft.com -- Universal Actions](https://learn.microsoft.com/en-us/microsoftteams/platform/task-modules-and-cards/cards/universal-actions-for-adaptive-cards/overview) +2. **Slack `respond({ delete_original: true })` → Teams `deleteActivity(activityId)`.** Slack's delete-original flag removes the message. In Teams, call `deleteActivity(activityId)` on the turn context. You must store the original activity ID (from the `send()` return value or `activity.replyToId`) to delete it later. [learn.microsoft.com -- Delete activity](https://learn.microsoft.com/en-us/azure/bot-service/rest-api/bot-framework-rest-connector-delete-activity) +3. **Slack `chat.update(channel, ts, ...)` → Teams `updateActivity(activityId, activity)`.** Both platforms support editing a bot's own message after sending. The key difference: Slack identifies messages by `channel + ts`, Teams uses `activityId` (returned from `send()`). Store the activity ID at send time. [learn.microsoft.com -- Update activity](https://learn.microsoft.com/en-us/azure/bot-service/rest-api/bot-framework-rest-connector-update-activity) +4. **Slack `chat.postEphemeral()` has NO Teams equivalent.** Ephemeral messages visible only to one user do not exist in Teams. Redesign strategies: (a) send a message in the user's 1:1 bot chat, (b) use `Action.Execute` with `refresh.userIds` to show per-user card content, (c) simply send a visible message if privacy is not critical, (d) use a task module/dialog for private interaction. [learn.microsoft.com -- Conversations](https://learn.microsoft.com/en-us/microsoftteams/platform/bots/how-to/conversations/conversation-basics) +5. **Deferred response pattern: send "processing..." card, update later.** Slack's `response_url` allows 5 follow-up messages within 30 minutes. Teams has no `response_url` concept. Instead: (a) return a "Processing..." card from the invoke handler immediately, (b) store the conversation reference and activity ID, (c) use proactive messaging to update the card when processing completes. No expiry limit on updates. [learn.microsoft.com -- Proactive messages](https://learn.microsoft.com/en-us/microsoftteams/platform/bots/how-to/conversations/send-proactive-messages) +6. **Slack `response_url` (30-min, 5 follow-ups) → `send()` / `updateActivity()` with no expiry.** Slack's response_url is a webhook with time and count limits. Teams' `send()` and `updateActivity()` work indefinitely as long as you have a valid conversation reference. This is actually more flexible — but requires you to store conversation references yourself. [github.com/microsoft/teams.ts](https://github.com/microsoft/teams.ts) +7. **`Action.Execute` with `refresh.userIds` enables per-user card views.** Slack broadcasts the same message to everyone; only the interacting user sees ephemeral responses. Teams' `Action.Execute` with `refresh` can show different card content to different users — up to 60 user IDs per card. When specified users view the card, Teams automatically invokes the bot to get their personalized version. [learn.microsoft.com -- User-specific views](https://learn.microsoft.com/en-us/microsoftteams/platform/task-modules-and-cards/cards/universal-actions-for-adaptive-cards/user-specific-views) +8. **Store activity IDs at send time.** Every `send()` in Teams returns an activity ID (or resource response). Store this ID if you need to update or delete the message later. Slack uses `channel + ts`; Teams uses a single opaque `activityId` string. Failing to store the ID means you cannot update the message. [github.com/microsoft/teams.ts](https://github.com/microsoft/teams.ts) +9. **Slack `respond({ response_type: 'in_channel' })` → `send()`.** Slack's `in_channel` response type makes an ephemeral-by-default response visible to everyone. In Teams, all bot messages are visible by default — simply call `send()`. There is no visibility toggle. [learn.microsoft.com -- Bot messages](https://learn.microsoft.com/en-us/microsoftteams/platform/bots/how-to/conversations/conversation-messages) +10. **Card action handler must return within 3 seconds.** Teams invoke activities (including `Action.Execute` and `Action.Submit`) require a synchronous response within ~3 seconds. If processing takes longer, return a "processing" card immediately and update asynchronously via proactive messaging. Slack's `response_url` had a 30-minute window; Teams' invoke has a 3-second window. [learn.microsoft.com -- Invoke activities](https://learn.microsoft.com/en-us/microsoftteams/platform/bots/how-to/conversations/conversation-messages) +11. **Reverse direction (Teams → Slack):** For Teams → Slack, map `updateActivity` to `respond({ replace_original: true })`, and card refresh (`Action.Execute` with `refresh.userIds`) to ephemeral messages via `chat.postEphemeral`. `deleteActivity` maps to `chat.delete(channel, ts)`. The 3-second invoke deadline has no Slack equivalent -- Slack's `response_url` gives 30 minutes, which is more lenient. + +## patterns + +### Card replacement flow (replace_original → invoke response) + +**Slack (before):** + +```typescript +import { App } from "@slack/bolt"; + +const app = new App({ + token: process.env.SLACK_BOT_TOKEN!, + signingSecret: process.env.SLACK_SIGNING_SECRET!, +}); + +// Send initial message with a button +app.command("/approve", async ({ ack, respond }) => { + await ack(); + await respond({ + response_type: "in_channel", + blocks: [ + { + type: "section", + text: { type: "mrkdwn", text: "Request #123 needs approval" }, + accessory: { + type: "button", + text: { type: "plain_text", text: "Approve" }, + action_id: "approve_request", + value: "123", + }, + }, + ], + }); +}); + +// Replace the original message when button is clicked +app.action("approve_request", async ({ ack, respond, body }) => { + await ack(); + await respond({ + replace_original: true, + blocks: [ + { + type: "section", + text: { + type: "mrkdwn", + text: `Request #123 — *Approved* by <@${body.user.id}>`, + }, + }, + ], + }); +}); +``` + +**Teams (after):** + +```typescript +import { App } from "@microsoft/teams.apps"; +import { ConsoleLogger } from "@microsoft/teams.common"; + +const app = new App({ + logger: new ConsoleLogger("my-bot", { level: "info" }), +}); + +// Send initial approval card +app.message(/^\/?approve$/i, async ({ send }) => { + const response = await send({ + attachments: [{ + contentType: "application/vnd.microsoft.card.adaptive", + content: { + type: "AdaptiveCard", + version: "1.5", + body: [ + { type: "TextBlock", text: "Request #123 needs approval", weight: "Bolder" }, + ], + actions: [{ + type: "Action.Execute", + title: "Approve", + verb: "approveRequest", + data: { requestId: "123" }, + }], + }, + }], + }); + // Store response.id if you need to update/delete later via proactive messaging +}); + +// Handle Action.Execute — return replacement card (replaces replace_original) +app.on("card.action" as any, async ({ activity }) => { + const data = activity.value?.action?.data ?? activity.value; + if (data?.verb === "approveRequest") { + const approver = activity.from?.name ?? "Someone"; + // Returning a card from the handler replaces the original card inline + return { + status: 200, + body: { + type: "AdaptiveCard", + version: "1.5", + body: [ + { + type: "TextBlock", + text: `Request #${data.requestId} — **Approved** by ${approver}`, + wrap: true, + }, + ], + // No actions = card becomes read-only after approval + }, + }; + } +}); + +app.start(3978); +``` + +### Deferred response with processing indicator + +**Slack (before):** + +```typescript +import { App } from "@slack/bolt"; + +const app = new App({ + token: process.env.SLACK_BOT_TOKEN!, + signingSecret: process.env.SLACK_SIGNING_SECRET!, +}); + +app.action("run_report", async ({ ack, respond }) => { + await ack(); + + // Immediate feedback + await respond({ replace_original: true, text: "Generating report..." }); + + // Long-running task — uses response_url (valid for 30 min, 5 follow-ups) + const report = await generateReport(); // takes 15 seconds + await respond({ + replace_original: true, + blocks: [ + { + type: "section", + text: { type: "mrkdwn", text: `Report ready: ${report.url}` }, + }, + ], + }); +}); +``` + +**Teams (after):** + +```typescript +import { App } from "@microsoft/teams.apps"; +import { ConsoleLogger } from "@microsoft/teams.common"; + +const app = new App({ + logger: new ConsoleLogger("my-bot", { level: "info" }), +}); + +// Store conversation references for proactive updates +const conversationRefs = new Map(); + +app.on("card.action" as any, async ({ activity, send }) => { + const data = activity.value?.action?.data ?? activity.value; + if (data?.verb === "runReport") { + // Store conversation reference for later proactive update + const convRef = { + conversationId: activity.conversation?.id, + serviceUrl: (activity as any).serviceUrl, + }; + + // Return "processing" card immediately (must respond within 3 seconds) + const processingCard = { + status: 200, + body: { + type: "AdaptiveCard", + version: "1.5", + body: [ + { type: "TextBlock", text: "Generating report...", isSubtle: true }, + { + type: "TextBlock", + text: "This may take a moment. The card will update when ready.", + wrap: true, + size: "Small", + }, + ], + }, + }; + + // Kick off async work — update the card when done + // No 30-minute expiry like Slack's response_url + setImmediate(async () => { + try { + const report = await generateReport(); + // Proactive message to update the card + await send({ + attachments: [{ + contentType: "application/vnd.microsoft.card.adaptive", + content: { + type: "AdaptiveCard", + version: "1.5", + body: [ + { type: "TextBlock", text: "Report Ready", weight: "Bolder" }, + { type: "TextBlock", text: `[Download Report](${report.url})`, wrap: true }, + ], + }, + }], + }); + } catch (err) { + await send("Report generation failed. Please try again."); + } + }); + + return processingCard; + } +}); + +async function generateReport() { + // Simulate long-running work + await new Promise((r) => setTimeout(r, 15000)); + return { url: "https://example.com/report.pdf" }; +} + +app.start(3978); +``` + +### Ephemeral workaround: `refresh.userIds` (R1) + +Use `Action.Execute` with `refresh.userIds` to show personalized card content to specific users — the closest Teams equivalent to Slack's `chat.postEphemeral()`. + +```typescript +// Send a card where only the acting user sees personalized content +async function sendWithEphemeralView( + send: (msg: any) => Promise, + actingUserId: string, + publicText: string, + privateData: Record +): Promise { + await send({ + attachments: [{ + contentType: "application/vnd.microsoft.card.adaptive", + content: { + type: "AdaptiveCard", + version: "1.4", + refresh: { + action: { + type: "Action.Execute", + verb: "personalView", + data: privateData, + }, + userIds: [actingUserId], // max 60 IDs + }, + body: [ + { type: "TextBlock", text: publicText }, // everyone sees this + ], + }, + }], + }); +} + +// When the specified user views the card, Teams invokes the bot: +app.on("card.action" as any, async ({ activity }) => { + const data = activity.value?.action?.data ?? activity.value; + if (data?.verb === "personalView") { + return { + status: 200, + body: { + type: "AdaptiveCard", + version: "1.4", + body: [ + { type: "TextBlock", text: "This content is only visible to you.", weight: "Bolder" }, + { type: "FactSet", facts: [ + { title: "Request ID", value: data.requestId }, + { title: "Status", value: "Pending your review" }, + ]}, + ], + }, + }; + } +}); +``` + +**Key constraints:** Max 60 user IDs per card. Requires `Action.Execute` (not `Action.Submit`). Manifest version must be ≥1.12. + +**Reverse (Teams → Slack):** Map card refresh to `chat.postEphemeral(channel, user, { blocks })`. + +### Card version checking (Y11) + +Inject a `_version` counter into `Action.Submit.data` to prevent race conditions — the Teams equivalent of Slack's `view_hash` parameter. + +```typescript +// Track version per card instance +const cardVersions = new Map(); + +function buildVersionedCard(cardId: string, data: any): object { + const version = (cardVersions.get(cardId) ?? 0) + 1; + cardVersions.set(cardId, version); + return { + type: "AdaptiveCard", version: "1.5", + body: [/* card content */], + actions: [{ + type: "Action.Submit", title: "Update", + data: { ...data, _cardId: cardId, _version: version }, + }], + }; +} + +app.on("card.action" as any, async ({ activity, send }) => { + const submitted = activity.value?.action?.data ?? activity.value; + const currentVersion = cardVersions.get(submitted?._cardId); + if (submitted?._version !== currentVersion) { + await send("This card is outdated. Please use the latest version."); + return { status: 200 }; + } + // Process the update safely... +}); +``` + +**Don't:** Skip version checking even for low-traffic bots — fast double-clicks and multiple tabs cause race conditions. + +**Reverse (Teams → Slack):** Use `view_hash` from `views.open()` / `views.update()` responses natively. + +### Response pattern mapping table + +| Slack Pattern | Teams Equivalent | Notes | +|---|---|---| +| `respond({ replace_original: true, blocks })` | Return card from `card.action` handler | Inline card replacement | +| `respond({ delete_original: true })` | `deleteActivity(activityId)` | Must store activity ID | +| `respond({ response_type: 'in_channel' })` | `send(text)` | All Teams messages are visible | +| `respond({ response_type: 'ephemeral' })` | *(no equivalent)* | Redesign: 1:1 chat, Action.Execute refresh, or visible | +| `chat.update(channel, ts, ...)` | `updateActivity(activityId, activity)` | Store activity ID from send() | +| `chat.delete(channel, ts)` | `deleteActivity(activityId)` | Store activity ID from send() | +| `chat.postEphemeral(channel, user, ...)` | *(no equivalent)* | Use Action.Execute `refresh.userIds` for per-user views | +| `response_url` (30-min, 5 follow-ups) | `send()` / `updateActivity()` | No expiry, no count limit | +| Button click → `ack()` + `respond()` | `card.action` handler → return card | No ack needed | + +## pitfalls + +- **Forgetting to store activity IDs**: Unlike Slack where `channel + ts` identifies any message, Teams requires the `activityId` returned from `send()`. If you don't store it, you cannot update or delete the message later. This is the #1 migration failure for interactive patterns. +- **3-second invoke timeout**: Slack's `ack()` gave you 3 seconds to acknowledge, then `response_url` gave 30 minutes for follow-up. Teams invoke handlers must return the full response (including replacement card) within ~3 seconds. Anything longer requires the deferred pattern (return processing card, update proactively). +- **No ephemeral messages — silent behavioral change**: Code using `chat.postEphemeral()` will not error during migration — it simply has no equivalent. The migrated bot must explicitly choose an alternative strategy. Audit all `postEphemeral` calls before migration. +- **`Action.Execute` vs `Action.Submit`**: `Action.Submit` sends data to the bot but does NOT support automatic card refresh or per-user views. `Action.Execute` (Universal Actions) supports both. Always use `Action.Execute` for interactive cards that need replacement or per-user content. Requires manifest version 1.12+. +- **`refresh.userIds` limit of 60**: The per-user card refresh feature (`Action.Execute` with `refresh.userIds`) supports a maximum of 60 user IDs per card. For broader audiences, send the base card to everyone and only personalize for the acting user. +- **Card replacement only works for invoke responses**: You can only replace a card inline by returning a new card from the invoke handler. If the interaction is not an invoke (e.g., a proactive message), you must use `updateActivity()` instead. +- **`deleteActivity` may not work in all contexts**: Deleting activities works in 1:1 and group chats but may be restricted in channels depending on permissions. Test deletion behavior in your target conversation types. + +## references + +- https://learn.microsoft.com/en-us/microsoftteams/platform/task-modules-and-cards/cards/universal-actions-for-adaptive-cards/overview +- https://learn.microsoft.com/en-us/microsoftteams/platform/task-modules-and-cards/cards/universal-actions-for-adaptive-cards/user-specific-views +- https://learn.microsoft.com/en-us/microsoftteams/platform/bots/how-to/conversations/send-proactive-messages +- https://learn.microsoft.com/en-us/azure/bot-service/rest-api/bot-framework-rest-connector-update-activity +- https://learn.microsoft.com/en-us/azure/bot-service/rest-api/bot-framework-rest-connector-delete-activity +- https://learn.microsoft.com/en-us/microsoftteams/platform/bots/how-to/conversations/conversation-messages +- https://github.com/microsoft/teams.ts +- https://api.slack.com/interactivity/handling — Slack interactive responses +- https://api.slack.com/methods/chat.update — Slack chat.update +- https://api.slack.com/methods/chat.postEphemeral — Slack chat.postEphemeral + +## instructions + +Use this expert when adding cross-platform support in either direction for Slack interactive response patterns or Teams card/message update patterns. It covers: `respond({ replace_original })` to invoke card replacement, `respond({ delete_original })` to `deleteActivity()`, `chat.update()` to `updateActivity()`, `chat.postEphemeral()` redesign strategies, deferred response patterns (processing card + proactive update), `response_url` elimination, and `Action.Execute` with `refresh.userIds` for per-user card views. For Teams → Slack, map `updateActivity` to `respond({ replace_original })`, and card refresh to ephemeral messages. Pair with `../teams/ui.adaptive-cards-ts.md` for card construction patterns, `../teams/runtime.proactive-messaging-ts.md` for deferred update infrastructure, and `events-activities-ts.md` for the underlying event/activity mapping. + +## research + +Deep Research prompt: + +"Write a micro expert for bridging Slack interactive response patterns (respond, replace_original, ephemeral) and Teams card/message update patterns in either direction for cross-platform bots. Cover: respond({ replace_original }) to invoke card replacement and vice versa, respond({ delete_original }) to deleteActivity, chat.update to updateActivity, chat.postEphemeral redesign strategies, response_url expiry semantics, deferred response patterns with processing indicators, Action.Execute with refresh.userIds for per-user views, reverse-direction mapping from Teams to Slack, and the 3-second invoke timeout constraint. Include side-by-side TypeScript code examples and a mapping table." diff --git a/plugins/microsoft-365-agents-toolkit/skills/teams-app-developer/experts/bridge/link-unfurl-preview-ts.md b/plugins/microsoft-365-agents-toolkit/skills/teams-app-developer/experts/bridge/link-unfurl-preview-ts.md new file mode 100644 index 0000000..123383a --- /dev/null +++ b/plugins/microsoft-365-agents-toolkit/skills/teams-app-developer/experts/bridge/link-unfurl-preview-ts.md @@ -0,0 +1,330 @@ +# link-unfurl-preview-ts + +## purpose + +Bridges Slack link unfurling (link_shared, chat.unfurl) and Teams link preview (messageHandlers) for cross-platform bots targeting Slack, Teams, or both. + +## rules + +1. **Slack `app.event('link_shared')` + `chat.unfurl()` → Teams `message.ext.query-link` handler.** Slack fires a `link_shared` event and the bot calls `chat.unfurl()` asynchronously. Teams uses a compose extension handler that must return the unfurl card synchronously. The handler name in the Teams SDK is `message.ext.query-link` (or the equivalent `composeExtension/queryLink` activity). [learn.microsoft.com -- Link unfurling](https://learn.microsoft.com/en-us/microsoftteams/platform/messaging-extensions/how-to/link-unfurling) +2. **Manifest `composeExtensions[].messageHandlers` with domain list is required.** Unlike Slack where you register unfurl domains in the app dashboard, Teams requires them in the manifest JSON under `composeExtensions[0].messageHandlers[0].value.domains`. Only URLs matching these domains trigger unfurling. [learn.microsoft.com -- Manifest schema](https://learn.microsoft.com/en-us/microsoftteams/platform/resources/schema/manifest-schema#composeextensionsmessagehandlers) +3. **Teams has a 5-second synchronous response deadline.** Slack's `link_shared` event allows async unfurling — the bot receives the event, processes it, then calls `chat.unfurl()` within 30 minutes. Teams' `query-link` is an invoke that must return the preview card within ~5 seconds. If data fetching takes longer, return a minimal card and cannot update later. [learn.microsoft.com -- Link unfurling](https://learn.microsoft.com/en-us/microsoftteams/platform/messaging-extensions/how-to/link-unfurling) +4. **The bot must be installed in the conversation for unfurling to work.** Slack link unfurling works in any channel where the app is installed (workspace-level). Teams link unfurling only works in conversations where the bot is explicitly installed. Users may need to @mention the bot or add it to the team/chat first. [learn.microsoft.com -- Link unfurling prerequisites](https://learn.microsoft.com/en-us/microsoftteams/platform/messaging-extensions/how-to/link-unfurling#prerequisites) +5. **No retroactive unfurling of already-posted links.** Slack can unfurl links in messages already posted (if the app is added later). Teams only unfurls links at the time they are composed/sent. Links in existing messages are never retroactively unfurled. [learn.microsoft.com -- Link unfurling](https://learn.microsoft.com/en-us/microsoftteams/platform/messaging-extensions/how-to/link-unfurling) +6. **Slack unfurl supports multiple links per message; Teams handles one at a time.** Slack's `link_shared` event includes an array of `links` from the message. Teams invokes the `query-link` handler once per URL. If a message contains multiple matching URLs, the handler is called multiple times. [learn.microsoft.com -- Link unfurling](https://learn.microsoft.com/en-us/microsoftteams/platform/messaging-extensions/how-to/link-unfurling) +7. **Return an Adaptive Card (not Hero/Thumbnail) for rich previews.** Slack unfurls return attachment objects with `title`, `text`, `thumb_url`, `color`. Teams link unfurling should return Adaptive Cards for the richest preview. The response format wraps the card in a `composeExtension` result with `type: "result"`. [learn.microsoft.com -- Cards in extensions](https://learn.microsoft.com/en-us/microsoftteams/platform/messaging-extensions/how-to/link-unfurling#response) +8. **Domain matching is exact — no wildcards for subdomains.** Slack unfurl domain matching supports wildcards. Teams manifest `messageHandlers.value.domains` requires exact domain entries. To match `foo.example.com` and `bar.example.com`, list both explicitly. [learn.microsoft.com -- Manifest domains](https://learn.microsoft.com/en-us/microsoftteams/platform/resources/schema/manifest-schema) +9. **Slack's unfurl `is_bot_token_only` flag → not applicable.** Slack distinguishes between user-token and bot-token unfurling. Teams link unfurling always runs as the bot identity. There is no user-token mode. [github.com/microsoft/teams.ts](https://github.com/microsoft/teams.ts) +10. **Cache unfurl results where possible.** Since the 5-second deadline is strict, cache API responses for frequently unfurled URLs. Slack's async model made caching less critical. In Teams, a cache miss that takes >5 seconds means the unfurl silently fails with no preview shown. [learn.microsoft.com -- Link unfurling](https://learn.microsoft.com/en-us/microsoftteams/platform/messaging-extensions/how-to/link-unfurling) +11. **Reverse direction (Teams → Slack):** For Teams → Slack, map `messageHandlers` domain config to `link_shared` event subscription (configured in the Slack app dashboard under Unfurl Domains), and preview card responses to `chat.unfurl` calls. The key advantage in reverse is that Slack's async model (`chat.unfurl` within 30 minutes) is more forgiving than Teams' 5-second synchronous deadline. Adaptive Card preview content maps to Slack unfurl attachment objects with `title`, `text`, `thumb_url`, and `color`. + +## patterns + +### link_shared → query-link handler migration + +**Slack (before):** + +```typescript +import { App } from "@slack/bolt"; + +const app = new App({ + token: process.env.SLACK_BOT_TOKEN!, + signingSecret: process.env.SLACK_SIGNING_SECRET!, +}); + +// Async unfurl — no time pressure +app.event("link_shared", async ({ event, client }) => { + const unfurls: Record = {}; + + for (const link of event.links) { + if (link.domain === "myapp.example.com") { + const match = link.url.match(/\/issues\/(\d+)/); + if (match) { + const issue = await fetchIssue(match[1]); // can take 10+ seconds + unfurls[link.url] = { + title: `Issue #${issue.id}: ${issue.title}`, + text: issue.description, + color: issue.status === "open" ? "#36a64f" : "#e01e5a", + thumb_url: issue.assignee?.avatarUrl, + footer: `Status: ${issue.status}`, + }; + } + } + } + + if (Object.keys(unfurls).length > 0) { + await client.chat.unfurl({ + ts: event.message_ts, + channel: event.channel, + unfurls, + }); + } +}); +``` + +**Teams (after):** + +```typescript +import { App } from "@microsoft/teams.apps"; +import { ConsoleLogger } from "@microsoft/teams.common"; + +const app = new App({ + logger: new ConsoleLogger("my-bot", { level: "info" }), +}); + +// Simple in-memory cache to meet 5-second deadline +const issueCache = new Map(); + +// Synchronous unfurl — must respond within 5 seconds +app.on("message.ext.query-link" as any, async ({ activity }) => { + const url: string = activity.value?.url ?? ""; + const match = url.match(/\/issues\/(\d+)/); + + if (!match) { + return { status: 200, body: {} }; // No preview for unrecognized URLs + } + + const issueId = match[1]; + let issue: any; + + // Check cache first (critical for meeting 5-second deadline) + const cached = issueCache.get(issueId); + if (cached && cached.expiry > Date.now()) { + issue = cached.data; + } else { + issue = await fetchIssue(issueId); + issueCache.set(issueId, { data: issue, expiry: Date.now() + 5 * 60_000 }); + } + + const statusColor = issue.status === "open" ? "good" : "attention"; + + return { + status: 200, + body: { + composeExtension: { + type: "result", + attachmentLayout: "list", + attachments: [{ + contentType: "application/vnd.microsoft.card.adaptive", + content: { + type: "AdaptiveCard", + version: "1.5", + body: [ + { + type: "TextBlock", + text: `Issue #${issue.id}: ${issue.title}`, + weight: "Bolder", + size: "Medium", + }, + { + type: "TextBlock", + text: issue.description, + wrap: true, + maxLines: 3, + }, + { + type: "ColumnSet", + columns: [ + { + type: "Column", + width: "auto", + items: [{ + type: "TextBlock", + text: `Status: **${issue.status}**`, + color: statusColor, + }], + }, + { + type: "Column", + width: "stretch", + items: [{ + type: "TextBlock", + text: issue.assignee?.name ? `Assigned: ${issue.assignee.name}` : "Unassigned", + isSubtle: true, + horizontalAlignment: "Right", + }], + }, + ], + }, + ], + actions: [{ + type: "Action.OpenUrl", + title: "View Issue", + url, + }], + }, + preview: { + contentType: "application/vnd.microsoft.card.thumbnail", + content: { + title: `Issue #${issue.id}: ${issue.title}`, + text: `Status: ${issue.status}`, + }, + }, + }], + }, + }, + }; +}); + +async function fetchIssue(id: string) { + return { id, title: "Login broken on Safari", description: "Users report...", status: "open", assignee: { name: "Alice", avatarUrl: "" } }; +} + +app.start(3978); +``` + +### Manifest domain configuration + +**Slack** — domains are configured in the Slack app dashboard under "Event Subscriptions > Unfurl Domains". + +**Teams** — domains must be in the manifest JSON: + +```json +{ + "composeExtensions": [ + { + "botId": "${{BOT_ID}}", + "messageHandlers": [ + { + "type": "link", + "value": { + "domains": [ + "myapp.example.com", + "issues.example.com" + ] + } + } + ], + "commands": [] + } + ] +} +``` + +### Unfurl mapping table + +| Slack Pattern | Teams Equivalent | Notes | +|---|---|---| +| `app.event('link_shared')` | `app.on('message.ext.query-link')` | Invoke-based, not event-based | +| `chat.unfurl(ts, channel, unfurls)` | Return card from handler | Synchronous response | +| Unfurl domains in app dashboard | Manifest `messageHandlers.value.domains` | JSON config, not web UI | +| Async unfurl (up to 30 min) | Synchronous (5-second deadline) | Must respond immediately | +| Multiple links in one event | One invoke per URL | Handler called N times | +| Wildcard domain matching | Exact domain matching only | List all subdomains explicitly | +| `is_bot_token_only` flag | *(not applicable)* | Always bot identity | +| Attachment unfurl format | Adaptive Card in composeExtension result | Richer card format | + +### Cache middleware best practice (Y7) + +The 5-second Teams deadline makes caching non-optional. Always use a cache layer for unfurl handlers. + +```typescript +// Reusable cache-first unfurl wrapper +const unfurlCache = new Map(); + +function withUnfurlCache( + fetchFn: (url: string) => Promise, + ttlMs: number = 300_000 // 5 min default +) { + return async (url: string): Promise => { + const cached = unfurlCache.get(url); + if (cached && cached.expires > Date.now()) { + return cached.data as T; + } + const data = await fetchFn(url); // must complete in <4 seconds + unfurlCache.set(url, { data, expires: Date.now() + ttlMs }); + return data; + }; +} + +// Usage +const cachedFetchIssue = withUnfurlCache( + async (url: string) => { + const id = url.match(/\/issues\/(\d+)/)?.[1]; + return id ? await fetchIssue(id) : null; + }, + 5 * 60_000 // 5 min TTL +); + +app.on("message.ext.query-link" as any, async ({ activity }) => { + const url: string = activity.value?.url ?? ""; + const issue = await cachedFetchIssue(url); + if (!issue) return { status: 200, body: {} }; + return buildUnfurlResponse(issue); +}); +``` + +**Best practices:** +- Set TTL based on data freshness needs (5–60 minutes) +- Pre-populate cache for known high-traffic URLs on startup +- Never make multiple API calls inside the unfurl handler — pre-fetch or batch +- For production, replace the `Map` with Redis or a shared cache + +**Don't:** Skip caching even for "fast" data sources. Network latency + cold starts can push you past 5 seconds. + +**Reverse (Teams → Slack):** Slack's 30-minute async model makes caching less critical, but still recommended for performance. + +## pitfalls + +- **Missing `messageHandlers` in manifest**: Without the `messageHandlers` array in `composeExtensions`, link unfurling never triggers. The bot receives no activity for matching URLs. This is the #1 deployment issue for link unfurling. +- **5-second deadline with no fallback**: If data fetching exceeds 5 seconds, the unfurl silently fails — no error card, no retry. Users see a plain URL with no preview. Implement aggressive caching and fast-path responses. +- **Bot must be installed in the conversation**: Unlike Slack where workspace-level app installation enables unfurling everywhere, Teams requires the bot to be installed in each team/chat where unfurling should work. Users may not understand why links aren't unfurling in some conversations. +- **No retroactive unfurling**: Existing messages with matching URLs are never unfurled when the bot is installed later. Only new messages trigger the handler. Slack supports unfurling existing messages. +- **Exact domain matching**: `*.example.com` is not supported. If your app has URLs across `app.example.com`, `api.example.com`, and `docs.example.com`, all three must be listed separately in the manifest. For apps with many subdomains, use a build-time manifest generator script (see Y15 pattern below). +- **Adaptive Card size limit**: Link preview cards are subject to the standard 28 KB Adaptive Card size limit. Keep previews concise — unfurl cards with embedded images or long descriptions may be silently truncated. + +### Domain wildcard workaround: manifest generator (Y15) + +Teams requires exact domain listing — no wildcards. For apps with many subdomains, automate manifest generation at build time. + +```typescript +// scripts/generate-manifest-domains.ts +import fs from "fs"; + +// Source of truth: your subdomain list (from config, DNS, or API) +const BASE_DOMAIN = "example.com"; +const SUBDOMAINS = ["app", "docs", "api", "staging", "portal", "admin"]; + +function generateManifestDomains(): string[] { + return SUBDOMAINS.map(sub => `${sub}.${BASE_DOMAIN}`); +} + +// Read the template manifest +const manifest = JSON.parse(fs.readFileSync("manifest.template.json", "utf8")); + +// Inject domains into composeExtensions messageHandlers +manifest.composeExtensions[0].messageHandlers[0].value.domains = generateManifestDomains(); + +// Also inject into validDomains (required for link unfurling) +manifest.validDomains = [ + ...new Set([...(manifest.validDomains ?? []), ...generateManifestDomains()]), +]; + +fs.writeFileSync("manifest.json", JSON.stringify(manifest, null, 2)); +console.log(`Generated manifest with ${SUBDOMAINS.length} domains.`); +``` + +Add to your build pipeline: `ts-node scripts/generate-manifest-domains.ts` before packaging. + +**Don't:** Try to register a single wildcard domain — Teams silently rejects it with no error message. + +**Reverse (Teams → Slack):** Slack supports `*.example.com` wildcards natively in the app dashboard. + +## references + +- https://learn.microsoft.com/en-us/microsoftteams/platform/messaging-extensions/how-to/link-unfurling +- https://learn.microsoft.com/en-us/microsoftteams/platform/resources/schema/manifest-schema#composeextensionsmessagehandlers +- https://learn.microsoft.com/en-us/microsoftteams/platform/messaging-extensions/what-are-messaging-extensions +- https://github.com/microsoft/teams.ts +- https://api.slack.com/reference/messaging/link-unfurling — Slack link unfurling +- https://api.slack.com/methods/chat.unfurl — Slack chat.unfurl + +## instructions + +Use this expert when adding cross-platform support in either direction for Slack link unfurling or Teams link preview. It covers: `link_shared` event to `message.ext.query-link` handler, `chat.unfurl()` to synchronous card response, manifest `messageHandlers` domain configuration, the 5-second response deadline, installation requirement, and the lack of retroactive unfurling. For Teams → Slack, map `messageHandlers` domain config to `link_shared` event subscription, and preview card responses to `chat.unfurl` calls. Pair with `../teams/ui.message-extensions-ts.md` for general message extension patterns, `../teams/runtime.manifest-ts.md` for manifest configuration, and `ui-block-kit-adaptive-cards-ts.md` for converting between Slack attachment unfurl format and Adaptive Cards. + +## research + +Deep Research prompt: + +"Write a micro expert for bridging Slack link unfurling (link_shared event + chat.unfurl) and Teams link preview (compose extension query-link handler, messageHandlers) in either direction for cross-platform bots. Cover: manifest messageHandlers domain configuration, the 5-second synchronous response deadline vs Slack's async model, bot installation requirement, no retroactive unfurling, exact domain matching, Adaptive Card response format, caching strategies, per-URL invocation, and reverse-direction mapping from Teams messageHandlers to Slack link_shared subscriptions and chat.unfurl calls. Include TypeScript code examples and a mapping table." diff --git a/plugins/microsoft-365-agents-toolkit/skills/teams-app-developer/experts/bridge/middleware-handlers-ts.md b/plugins/microsoft-365-agents-toolkit/skills/teams-app-developer/experts/bridge/middleware-handlers-ts.md new file mode 100644 index 0000000..0be698e --- /dev/null +++ b/plugins/microsoft-365-agents-toolkit/skills/teams-app-developer/experts/bridge/middleware-handlers-ts.md @@ -0,0 +1,328 @@ +# middleware-handlers-ts + +## purpose + +Bridges Slack Bolt middleware chains and Teams SDK handler patterns for cross-platform bots targeting Slack, Teams, or both. + +## rules + +1. Slack Bolt uses an explicit middleware chain: `app.use((args) => { ... await next(); })` for global middleware, and per-listener middleware as extra arguments to `app.message()`, `app.action()`, etc. Teams SDK v2 uses `app.on()` route handlers that execute in registration order with no explicit `next()` call. [github.com/microsoft/teams.ts](https://github.com/microsoft/teams.ts) +2. Slack's `next()` function must be called to pass control to the next middleware. In Teams, all matching handlers for a route execute — there is no `next()` to call. To short-circuit (prevent later handlers), return early or use a guard pattern. +3. Slack global middleware (`app.use()`) runs on EVERY request before any listener. In Teams, register a `app.on('message', ...)` handler FIRST (before other message handlers) to achieve the same effect. Handler registration order determines execution order. +4. Slack listener middleware (per-handler) like `app.message(authMiddleware, actualHandler)` has no direct Teams equivalent. Refactor as: (a) a shared guard function called at the top of each handler, (b) a wrapper/decorator function that wraps handlers, or (c) a first-registered catch-all handler that sets context. +5. Slack's `ack()` (acknowledge within 3 seconds) has NO equivalent in Teams. Teams does not require acknowledgement — the Bot Framework handles the HTTP response automatically. Remove all `ack()` calls and restructure code that splits work into "before ack" and "after ack" phases. +6. Slack's `say()` (post to the conversation where the event occurred) maps directly to Teams' `send()`. Both send a message to the current conversation. Slack's `respond()` (respond to the original webhook URL) maps to `send()` for new messages or `ctx.updateActivity()` for updating the original message. The webhook URL pattern does not exist in Teams. +7. Slack's `context` object (custom properties attached via middleware) → Teams uses the activity object and handler arguments directly. For shared state across handlers, use `app.state` or closure-scoped variables. +8. Slack error middleware (`app.error(async (error) => { ... })`) → Teams error handling via try/catch in individual handlers or a global `app.on('error', ...)` handler. The error shape differs significantly: Slack provides a destructured object `{ error, context, body }` where `context` contains bot/team metadata and `body` contains the full event payload, while Teams provides the raw `Error` object plus the activity context via handler arguments. For Teams → Slack: wrap the raw Error with context/body metadata to match Slack's shape. +9. The Java Slack SDK's formal middleware chain (`Middleware` interface with `apply(req, resp, chain)` → `chain.next(req, resp)`) is structurally identical to Express middleware. When converting Java middleware, first understand the intent, then rewrite as a Teams guard function or wrapper. +10. Slack's authorization middleware (built-in, validates tokens per workspace in multi-tenant apps) is replaced by Bot Framework JWT validation (automatic) and Azure AD authentication. Remove custom authorization middleware entirely. + +## patterns + +### Slack global middleware → Teams first-registered handler + +**Slack (before):** + +```typescript +import { App, NextFn } from '@slack/bolt'; + +const app = new App({ token: '...', signingSecret: '...' }); + +// Global middleware: runs on every request +app.use(async ({ next, logger, body }) => { + logger.info(`Request type: ${body.type}`); + const start = Date.now(); + await next(); + logger.info(`Completed in ${Date.now() - start}ms`); +}); + +// Global auth middleware +app.use(async ({ next, context, client }) => { + const authResult = await client.auth.test(); + context.botUserId = authResult.user_id; + await next(); +}); + +app.message(/hello/i, async ({ say }) => { + await say('Hi there!'); +}); +``` + +**Teams (after):** + +```typescript +import { App } from '@microsoft/teams.apps'; +import { ConsoleLogger } from '@microsoft/teams.common'; +import pino from 'pino'; + +const log = pino({ name: 'my-bot' }); + +const app = new App({ + logger: new ConsoleLogger('my-bot', { level: 'info' }), +}); + +// No global middleware API — use first-registered handlers instead. +// Logging is built into the Teams SDK via ConsoleLogger. +// Auth is handled automatically by Bot Framework JWT validation. + +// For cross-cutting concerns, use a wrapper function: +function withLogging Promise>( + handler: T, +): T { + return (async (...args: any[]) => { + const start = Date.now(); + try { + await handler(...args); + } finally { + log.info(`Handler completed in ${Date.now() - start}ms`); + } + }) as T; +} + +app.message( + /hello/i, + withLogging(async ({ send }) => { + await send('Hi there!'); + }), +); +``` + +### Slack listener middleware (per-handler auth) → Teams guard function + +**Slack (before):** + +```typescript +// Listener middleware: only this handler requires admin check +async function requireAdmin({ message, client, next }: any) { + const userInfo = await client.users.info({ user: message.user }); + if (userInfo.user?.is_admin) { + await next(); // allow handler to proceed + } + // Not calling next() short-circuits the chain +} + +app.message(/^!admin/, requireAdmin, async ({ message, say }) => { + await say(`Admin command received from <@${message.user}>`); +}); +``` + +**Teams (after):** + +```typescript +// Guard function: replaces listener middleware +async function isAdmin(aadObjectId: string): Promise { + // Check admin status via Graph API or custom logic + const adminIds = new Set([process.env.ADMIN_AAD_ID]); + return adminIds.has(aadObjectId); +} + +app.message(/^!admin/, async ({ activity, send }) => { + // Guard at the top of the handler (replaces middleware chain) + if (!(await isAdmin(activity.from.aadObjectId ?? ''))) { + await send('You must be an admin to use this command.'); + return; // Early return replaces "not calling next()" + } + + await send(`Admin command received from ${activity.from.name}`); +}); +``` + +### Java SDK middleware chain → Teams handler wrapper + +**Java (before):** + +```java +// Formal middleware interface +public class LoggingMiddleware implements Middleware { + @Override + public Response apply(Request req, Response resp, MiddlewareChain chain) throws Exception { + long start = System.currentTimeMillis(); + logger.info("Processing: {}", req.getRequestType()); + Response result = chain.next(req); + logger.info("Completed in {}ms", System.currentTimeMillis() - start); + return result; + } +} + +public class RateLimitMiddleware implements Middleware { + private final RateLimiter limiter; + + @Override + public Response apply(Request req, Response resp, MiddlewareChain chain) throws Exception { + if (!limiter.tryAcquire(req.getContext().getTeamId())) { + return Response.builder().statusCode(429).body("Rate limited").build(); + } + return chain.next(req); + } +} + +// Registration +app.use(new LoggingMiddleware()); +app.use(new RateLimitMiddleware(limiter)); +``` + +**Teams TypeScript (after):** + +```typescript +// Middleware becomes wrapper functions (no formal chain) +import pino from 'pino'; + +const log = pino({ name: 'my-bot' }); + +// Rate limiter as a guard utility +class RateLimiter { + private counts = new Map(); + + tryAcquire(key: string, limit = 10, windowMs = 60_000): boolean { + const now = Date.now(); + const entry = this.counts.get(key); + if (!entry || now > entry.resetAt) { + this.counts.set(key, { count: 1, resetAt: now + windowMs }); + return true; + } + if (entry.count >= limit) return false; + entry.count++; + return true; + } +} + +const limiter = new RateLimiter(); + +// Handler wrapper that combines logging + rate limiting +type MessageHandler = (ctx: any) => Promise; + +function withMiddleware(handler: MessageHandler): MessageHandler { + return async (ctx) => { + const start = Date.now(); + const tenantId = ctx.activity.channelData?.tenant?.id ?? 'unknown'; + + // Rate limiting (replaces RateLimitMiddleware) + if (!limiter.tryAcquire(tenantId)) { + await ctx.send('Rate limited. Please try again later.'); + return; + } + + // Logging (replaces LoggingMiddleware) + log.info({ type: ctx.activity.type }, 'Processing'); + try { + await handler(ctx); + } finally { + log.info(`Completed in ${Date.now() - start}ms`); + } + }; +} + +// Apply to handlers +app.message(/^!deploy/, withMiddleware(async ({ send }) => { + await send('Deploying...'); +})); +``` + +### Removing ack() and restructuring pre/post-ack logic + +**Slack (before):** + +```typescript +app.command('/deploy', async ({ ack, respond, command }) => { + // Must ack within 3 seconds + await ack('Starting deployment...'); + + // Slow work happens AFTER ack (Slack already got the 200 OK) + const result = await runDeployment(command.text); + await respond(`Deployment ${result.status}: ${result.url}`); +}); +``` + +**Teams (after):** + +```typescript +app.message(/^\/deploy\s*(.*)/i, async ({ send, activity }) => { + // No ack() needed — Teams handles the HTTP response + // Send an immediate response (replaces ack with message) + await send('Starting deployment...'); + + // Slow work — just do it inline, no pre/post-ack split needed + const target = activity.text?.match(/^\/deploy\s*(.*)/i)?.[1] ?? ''; + const result = await runDeployment(target); + await send(`Deployment ${result.status}: ${result.url}`); +}); +``` + +### say() → send() and error handling differences + +**Slack (before):** + +```typescript +// say() posts to the conversation where the event occurred +app.message(/help/i, async ({ say, message }) => { + await say(`Hey <@${message.user}>, here's what I can do...`); +}); + +// Global error handler — receives { error, context, body } +app.error(async ({ error, context, body }) => { + console.error(`Error in team ${context.teamId}:`, error.message); + console.error('Event body:', body.type); + // context has botUserId, teamId, etc. set by middleware + // body has the full Slack event payload +}); +``` + +**Teams (after):** + +```typescript +// send() is the Teams equivalent of say() — posts to the current conversation +app.message(/help/i, async ({ send, activity }) => { + await send(`Hey ${activity.from.name}, here's what I can do...`); +}); + +// Global error handler — receives the raw Error + activity context +app.on('error', async ({ error, activity }) => { + // Teams provides the raw Error object, not { error, context, body } + console.error(`Error in tenant ${activity?.conversation?.tenantId}:`, (error as Error).message); + console.error('Activity type:', activity?.type); + // No context bag — use activity properties directly + // No body — the activity IS the event payload +}); +``` + +### Reverse direction (Teams → Slack) + +For Teams → Slack, convert handler wrappers/guards back to formal middleware chains with `next()`. Add `ack()` calls where required. Key reverse mappings: +- Wrapper/decorator functions → `app.use(async ({ next, ... }) => { ... await next(); })` for global middleware +- Guard functions at top of handler → listener middleware: `app.message(guardMiddleware, actualHandler)` +- Early `return` for short-circuit → omit `await next()` to stop the chain +- `send()` for interim status → `ack('status message')` for immediate acknowledgement within 3 seconds +- `ctx.updateActivity()` → `respond({ replace_original: true, ... })` +- `app.on('error', ...)` → `app.error(async ({ error, context, body }) => { ... })` +- Handler registration order → explicit `app.use()` registration order for middleware chain +- Closure-scoped state / `app.state` → `context` object properties set by middleware (e.g., `context.botUserId`) +- Inline sequential work → split into pre-`ack()` (fast) and post-`ack()` (slow) phases where needed +- Bot Framework JWT validation (automatic, remove) → add `signingSecret` to Bolt config for request verification + +## pitfalls + +- **Looking for `next()`**: Teams has no middleware chain with `next()`. Every registered handler for a matching route runs. Stop thinking in chains and think in "ordered handler list." +- **Porting `ack()` as an empty response**: `ack()` is a Slack-specific 3-second HTTP response requirement. Teams has no equivalent. Remove it entirely — don't replace it with an empty `send()`. +- **Porting `respond()` URL-based replies**: Slack's `respond()` uses a `response_url` webhook. Teams has no response URL concept. Replace with `send()` for new messages or `ctx.updateActivity()` for updating existing messages. +- **Middleware that sets `context` properties**: Slack middleware often attaches custom data to `context` (e.g., `context.botUserId`). In Teams, use the handler's arguments directly (`activity.recipient.id` for bot ID) or closure-scoped state. There is no mutable `context` bag. +- **Authorization middleware being ported**: Slack's built-in `authorize` function (multi-tenant token lookup) and custom auth middleware should NOT be ported. Bot Framework JWT validation is automatic. Remove all token verification middleware. +- **Pre/post `ack()` split logic**: Slack apps commonly split handlers into "before ack" (fast, returns 200) and "after ack" (slow, async work). In Teams, this split is unnecessary — just do the work sequentially. Send an interim status message if the user needs feedback while waiting. +- **Java `MiddlewareChain.next()` return value**: Java middleware can inspect the Response returned by `chain.next()` and modify it. Teams handlers don't return responses to a chain — they call `send()` directly. Post-processing middleware must become wrapper functions. + +## references + +- https://slack.dev/bolt-js/concepts/global-middleware -- Slack Bolt global middleware +- https://slack.dev/bolt-js/concepts/listener-middleware -- Slack listener middleware +- https://api.slack.com/interactivity/handling#acknowledgment_response -- Slack ack() requirement +- https://github.com/microsoft/teams.ts -- Teams SDK v2 handler patterns +- https://learn.microsoft.com/en-us/azure/bot-service/rest-api/bot-framework-rest-connector-authentication -- Bot Framework auth (replaces Slack signing secret) + +## instructions + +Use this expert when bridging Slack middleware patterns and Teams handler patterns in either direction. The key conceptual shift is: Slack uses a formal middleware chain with `next()` and `ack()`, while Teams uses ordered route handlers with no chain, no acknowledgement requirement, and automatic authentication. For Slack → Teams: replace global middleware with first-registered handlers or wrappers, replace listener middleware with guards, remove `ack()`, remove authorization middleware. For Teams → Slack: convert wrappers/guards back to formal middleware chains with `next()`, add `ack()` calls, add signing secret verification. Pair with `events-activities-ts.md` for the event/route mapping and `../teams/runtime.routing-handlers-ts.md` for Teams handler registration patterns. + +## research + +Deep Research prompt: + +"Write a micro expert for bridging Slack Bolt middleware and Teams SDK v2 handler patterns bidirectionally. Cover: global middleware (app.use with next()) <-> first-registered handlers, listener middleware <-> guard functions, ack() addition/removal strategy, respond() <-> send()/updateActivity(), Java Middleware interface <-> TypeScript wrapper functions, authorization middleware bridging, context property migration, error handling middleware, and pre/post-ack logic restructuring. Include 4 worked examples covering both directions." diff --git a/plugins/microsoft-365-agents-toolkit/skills/teams-app-developer/experts/bridge/python-cross-platform.md b/plugins/microsoft-365-agents-toolkit/skills/teams-app-developer/experts/bridge/python-cross-platform.md new file mode 100644 index 0000000..8a53a27 --- /dev/null +++ b/plugins/microsoft-365-agents-toolkit/skills/teams-app-developer/experts/bridge/python-cross-platform.md @@ -0,0 +1,180 @@ +# python-cross-platform + +## purpose + +Unified Python server architecture for dual-platform Slack + Teams bots — combining `slack_bolt` and `microsoft_teams` in a single Python codebase. + +## rules + +1. Use **FastAPI** as the shared web framework. The Teams Python SDK uses FastAPI internally, and Slack Bolt has an `AsyncSlackRequestHandler` adapter for FastAPI. Both SDKs can share one FastAPI app and one process. [slack_bolt.adapter.fastapi, microsoft_teams.apps] +2. Mount the Slack handler at `/slack/events` and let the Teams SDK handle `/api/messages` (its default). Both endpoints run in the same FastAPI process, each routing to its own SDK. [FastAPI route mounting] +3. Use `AsyncApp` (not sync `App`) for Slack Bolt when combining with Teams, since the Teams SDK is async-only. Mixing sync Slack Bolt with async Teams SDK in one process causes event loop conflicts. [slack_bolt.async_app] +4. Build a **shared service layer** between platforms. Platform handlers call the same business logic — the Slack handler converts Slack payloads to service calls, and the Teams handler converts Teams activities to the same service calls. This mirrors the TS cross-platform architecture pattern. [experts/bridge/cross-platform-architecture-ts.md] +5. For AI features, use a single model client shared between platforms. Both `slack_bolt` handlers and `microsoft_teams` handlers can call the same OpenAI/Azure OpenAI client. Do not duplicate model initialization per platform. [shared service pattern] +6. Handle platform-specific UI by converting between Block Kit (Slack) and Adaptive Cards (Teams) at the adapter layer. The service layer returns platform-agnostic data; each platform adapter formats it for its UI framework. [experts/bridge/block-kit-to-adaptive-cards-ts.md concepts] +7. Use a single `.env` file for both platforms' credentials: `SLACK_BOT_TOKEN`, `SLACK_SIGNING_SECRET`, `SLACK_APP_TOKEN` (if Socket Mode) for Slack; `CLIENT_ID`, `CLIENT_SECRET` for Teams. Load with `python-dotenv`. [environment config] +8. For local development, use Slack Socket Mode (no public URL needed) alongside the Teams SDK's HTTP endpoint. The Slack `SocketModeHandler` runs in a background thread while FastAPI serves Teams on a port. [slack_bolt.adapter.socket_mode] +9. Store user identity mappings between platforms. A Slack user ID (`U...`) and a Teams user AAD object ID are different identifiers for the same person. Build a mapping table keyed by email or employee ID. [experts/bridge/identity-linking-ts.md concepts] +10. Deploy as a single container or process. Both SDKs share the Python runtime, dependencies, and service layer. Use `uvicorn` to run the FastAPI app, with Slack Socket Mode starting as a background task if needed. [deployment pattern] + +## patterns + +### Unified FastAPI server with both SDKs + +```python +import asyncio +import os +from contextlib import asynccontextmanager +from fastapi import FastAPI, Request +from slack_bolt.async_app import AsyncApp +from slack_bolt.adapter.fastapi import AsyncSlackRequestHandler +from microsoft_teams.apps import App as TeamsApp, ActivityContext +from microsoft_teams.api import MessageActivity + +# --- Shared service layer --- +async def handle_greeting(user_name: str) -> str: + return f"Hello, {user_name}! How can I help?" + +async def handle_status_request() -> dict: + return {"api": "healthy", "db": "healthy", "queue": "degraded"} + +# --- Slack setup --- +slack_app = AsyncApp( + token=os.environ["SLACK_BOT_TOKEN"], + signing_secret=os.environ["SLACK_SIGNING_SECRET"], +) + +@slack_app.message("hello") +async def slack_hello(message, say): + result = await handle_greeting(f"<@{message['user']}>") + await say(result) + +@slack_app.command("/status") +async def slack_status(ack, respond): + await ack("Checking...") + status = await handle_status_request() + await respond( + f"API: {status['api']} | DB: {status['db']} | Queue: {status['queue']}" + ) + +slack_handler = AsyncSlackRequestHandler(slack_app) + +# --- Teams setup --- +teams_app = TeamsApp( + client_id=os.environ.get("CLIENT_ID"), + client_secret=os.environ.get("CLIENT_SECRET"), +) + +@teams_app.on_message_pattern(r"^hello") +async def teams_hello(ctx: ActivityContext[MessageActivity]): + user_name = ctx.activity.from_property.name or "there" + result = await handle_greeting(user_name) + await ctx.send(result) + +@teams_app.on_message_pattern(r"^status$") +async def teams_status(ctx: ActivityContext[MessageActivity]): + status = await handle_status_request() + await ctx.send( + f"API: {status['api']} | DB: {status['db']} | Queue: {status['queue']}" + ) + +# --- FastAPI combines both --- +@asynccontextmanager +async def lifespan(app: FastAPI): + # Start Teams SDK in background + asyncio.create_task(teams_app.start(port=None)) + yield + +fastapi_app = FastAPI(lifespan=lifespan) + +@fastapi_app.post("/slack/events") +async def slack_events(req: Request): + return await slack_handler.handle(req) + +# Teams registers its own /api/messages route via HttpPlugin +# Mount Teams routes into the shared FastAPI app +fastapi_app.mount("/", teams_app.http.app) + +if __name__ == "__main__": + import uvicorn + uvicorn.run(fastapi_app, host="0.0.0.0", port=3000) +``` + +### Platform adapter pattern for UI conversion + +```python +from dataclasses import dataclass +from typing import Any + +@dataclass +class StatusCard: + """Platform-agnostic data structure""" + title: str + fields: dict[str, str] + action_label: str + +def to_slack_blocks(card: StatusCard) -> list[dict[str, Any]]: + """Convert to Slack Block Kit""" + fields = [ + {"type": "mrkdwn", "text": f"*{k}:* {v}"} + for k, v in card.fields.items() + ] + return [ + {"type": "section", "text": {"type": "mrkdwn", "text": f"*{card.title}*"}}, + {"type": "section", "fields": fields}, + { + "type": "actions", + "elements": [ + { + "type": "button", + "text": {"type": "plain_text", "text": card.action_label}, + "action_id": "refresh_status", + } + ], + }, + ] + +def to_adaptive_card(card: StatusCard) -> dict[str, Any]: + """Convert to Teams Adaptive Card""" + facts = [{"title": k, "value": v} for k, v in card.fields.items()] + return { + "type": "AdaptiveCard", + "version": "1.4", + "body": [ + {"type": "TextBlock", "text": card.title, "weight": "Bolder"}, + {"type": "FactSet", "facts": facts}, + ], + "actions": [ + {"type": "Action.Submit", "title": card.action_label} + ], + } +``` + +## pitfalls + +- **Event loop conflicts**: Mixing sync Slack `App` with async Teams SDK causes `RuntimeError: This event loop is already running`. Always use `AsyncApp` for Slack when combining with Teams. +- **Port collision**: Both SDKs default to different ports (Slack: 3000, Teams: 3978). When combining, use one port for the shared FastAPI app and configure both SDKs to use it. +- **Double handling**: If both Slack and Teams are mounted on the same FastAPI app, ensure routes don't overlap. Slack uses `/slack/events`, Teams uses `/api/messages` — keep them separate. +- **Python version**: The Teams Python SDK requires **Python 3.12+**. Slack Bolt supports 3.9+. The combined project must use 3.12+ to satisfy both. +- **Credential isolation**: Never mix Slack tokens with Teams credentials. Use clear env var prefixes (`SLACK_*` for Slack, `CLIENT_*` / `AZURE_*` for Teams) to avoid accidental cross-contamination. +- **No Python-specific TS experts**: All architecture and bridging experts (`cross-platform-architecture-ts.md`, `block-kit-to-adaptive-cards-ts.md`, etc.) contain TypeScript code. Use them for design patterns but translate all code to Python. + +## references + +- https://slack.dev/bolt-python/concepts +- https://slack.dev/bolt-python/concepts/adapters +- teams.py source: packages/apps/src/microsoft_teams/apps/ +- experts/bridge/cross-platform-architecture-ts.md (patterns to adapt) +- experts/bridge/block-kit-to-adaptive-cards-ts.md (UI conversion concepts) + +## instructions + +This expert covers the unified Python server architecture for Tier 2 dual-platform bots. Use it when building a Python bot that serves both Slack and Teams from a single codebase. It covers the FastAPI integration pattern, shared service layer, platform adapters, and deployment model. + +Pair with: `slack/bolt-python.md` for Slack-side Python SDK details. `teams/teams-python.md` for Teams-side Python SDK details. `bridge/cross-platform-architecture-ts.md` for architectural patterns (translate to Python). `bridge/block-kit-to-adaptive-cards-ts.md` for UI conversion concepts (translate to Python). `bridge/identity-linking-ts.md` for user mapping concepts. + +## research + +Deep Research prompt: + +"Write a micro expert on building a unified Python server that combines Slack Bolt (slack_bolt AsyncApp) and Microsoft Teams SDK (microsoft_teams) in a single FastAPI application. Cover FastAPI route mounting (/slack/events for Slack, /api/messages for Teams), shared service layer pattern, platform adapter pattern for Block Kit vs Adaptive Cards, environment configuration for both platforms, Socket Mode for local dev alongside HTTP for Teams, async-only requirement, Python 3.12+ version constraint, deployment as single container, and identity mapping between Slack user IDs and Teams AAD object IDs. Source from slack_bolt adapter.fastapi, microsoft_teams.apps HttpPlugin, and cross-platform architecture patterns." diff --git a/plugins/microsoft-365-agents-toolkit/skills/teams-app-developer/experts/bridge/rate-limiting-resilience-ts.md b/plugins/microsoft-365-agents-toolkit/skills/teams-app-developer/experts/bridge/rate-limiting-resilience-ts.md new file mode 100644 index 0000000..0d67013 --- /dev/null +++ b/plugins/microsoft-365-agents-toolkit/skills/teams-app-developer/experts/bridge/rate-limiting-resilience-ts.md @@ -0,0 +1,367 @@ +# rate-limiting-resilience-ts + +## purpose + +Bridges Slack and Teams rate limiting patterns, retry logic, and resilience strategies for cross-platform bots targeting Slack, Teams, or both. + +## rules + +1. **Slack 429 + `Retry-After` header → same pattern for Bot Framework and Graph API.** Both platforms return HTTP 429 with a `Retry-After` header (seconds) when throttled. The retry pattern is identical: wait the specified duration, then retry. The difference is in the rate limits themselves. [learn.microsoft.com -- Graph throttling](https://learn.microsoft.com/en-us/graph/throttling) +2. **Slack Bolt retry config → manual retry with exponential backoff + jitter.** Slack Bolt has built-in retry (`retryConfig: { retries: 3 }`). The Teams SDK does not have built-in retry. Implement exponential backoff with jitter: `delay = min(baseDelay * 2^attempt + random(0, jitter), maxDelay)`. [learn.microsoft.com -- Retry guidance](https://learn.microsoft.com/en-us/azure/architecture/best-practices/retry-service-specific) +3. **Teams Bot Framework rate limits: ~1 msg/sec per conversation, ~30 msg/min per conversation.** These are soft limits that vary by channel type (1:1 vs group vs channel). Exceeding them results in 429 responses. Slack's rate limits are per-method (e.g., `chat.postMessage` at ~1/sec per token). Teams limits are per-conversation, not per-method. [learn.microsoft.com -- Bot rate limiting](https://learn.microsoft.com/en-us/microsoftteams/platform/bots/how-to/rate-limit) +4. **Graph API has separate throttling from Bot Framework.** Graph API rate limits are per-app and per-tenant, varying by API. Common limits: 10,000 requests/10 minutes per app, with lower limits for specific APIs (e.g., channel messages). Graph 429s include `Retry-After` headers. These are independent of Bot Framework message rate limits. [learn.microsoft.com -- Graph throttling](https://learn.microsoft.com/en-us/graph/throttling) +5. **Proactive broadcast to many conversations needs a send queue.** Sending the same message to 500 users at once will hit rate limits. Implement a queue with concurrency control: process N messages concurrently, respect per-conversation limits, and handle 429s with retry. Use `p-limit`, `p-queue`, or a custom queue. [npmjs.com/p-queue](https://www.npmjs.com/package/p-queue) +6. **Circuit breaker pattern (`opossum`) protects against cascading failures.** When an external service (your database, a third-party API) is down, the bot should fail fast instead of timing out on every request. Use `opossum` to wrap external calls: after N failures, the circuit opens and rejects immediately for a cooldown period. [npmjs.com/opossum](https://www.npmjs.com/package/opossum) +7. **Slack `slack_api_error` with `response.headers['retry-after']` → same extraction pattern for Teams.** The error handling pattern is similar: catch HTTP errors, check for 429 status, extract `Retry-After`, and schedule retry. The API client libraries differ but the logic is identical. [learn.microsoft.com -- Graph error handling](https://learn.microsoft.com/en-us/graph/errors) +8. **Bot Framework Connector API has a separate 30-second timeout.** Beyond rate limits, the Bot Framework Connector API has a response timeout. If the bot doesn't respond to an invoke within ~3-10 seconds (depending on activity type), the Connector may retry or time out. This is separate from rate limiting but can compound issues under load. [learn.microsoft.com -- Bot Framework](https://learn.microsoft.com/en-us/azure/bot-service/bot-service-overview) +9. **Graph API batch requests reduce API call volume.** Instead of N individual Graph API calls, batch up to 20 requests in a single `POST /$batch` call. This counts as fewer requests against rate limits and reduces network overhead. Useful for bulk channel operations, user lookups, or file operations. [learn.microsoft.com -- JSON batching](https://learn.microsoft.com/en-us/graph/json-batching) +10. **Log and monitor throttling events.** Unlike Slack where Bolt logs retries automatically, Teams throttling must be explicitly logged. Track: 429 count, average retry delay, circuit breaker state, queue depth. Use Application Insights custom metrics or console logging. Throttling spikes indicate you're approaching platform limits. [learn.microsoft.com -- App Insights](https://learn.microsoft.com/en-us/azure/azure-monitor/app/nodejs) +11. **Reverse direction (Teams → Slack):** For Teams → Slack, Slack Bolt provides built-in `retryConfig`. Map custom Teams retry plugins to Bolt's retry configuration. Slack rate limits are per-method-per-token (not per-conversation like Teams). The `p-queue` and circuit breaker patterns apply equally in both directions. For Graph API batch requests, there is no Slack equivalent — individual API calls are needed but Bolt's built-in retry handles 429s automatically. + +## patterns + +### Exponential backoff wrapper + +**Slack (before):** + +```typescript +import { App } from "@slack/bolt"; + +const app = new App({ + token: process.env.SLACK_BOT_TOKEN!, + signingSecret: process.env.SLACK_SIGNING_SECRET!, + // Built-in retry handling + retryConfig: { + retries: 3, + factor: 2, // exponential backoff + }, +}); + +// Bolt automatically retries on 429 +app.message(/hello/i, async ({ say }) => { + await say("Hello!"); // auto-retried on rate limit +}); +``` + +**Teams (after) — manual retry wrapper:** + +```typescript +import { App } from "@microsoft/teams.apps"; +import { ConsoleLogger } from "@microsoft/teams.common"; + +const logger = new ConsoleLogger("my-bot", { level: "info" }); + +const app = new App({ + logger, +}); + +// Exponential backoff with jitter — replaces Bolt's retryConfig +async function withRetry( + fn: () => Promise, + options: { + maxRetries?: number; + baseDelayMs?: number; + maxDelayMs?: number; + jitterMs?: number; + } = {} +): Promise { + const { + maxRetries = 3, + baseDelayMs = 1000, + maxDelayMs = 30_000, + jitterMs = 500, + } = options; + + for (let attempt = 0; attempt <= maxRetries; attempt++) { + try { + return await fn(); + } catch (err: any) { + const status = err?.statusCode ?? err?.response?.status ?? err?.code; + const isRetryable = status === 429 || status === 503 || status === 502; + + if (!isRetryable || attempt === maxRetries) { + throw err; + } + + // Use Retry-After header if available, otherwise exponential backoff + const retryAfterSec = err?.response?.headers?.["retry-after"]; + let delay: number; + + if (retryAfterSec) { + delay = parseInt(retryAfterSec, 10) * 1000; + } else { + delay = Math.min(baseDelayMs * Math.pow(2, attempt), maxDelayMs); + } + + // Add jitter to prevent thundering herd + delay += Math.random() * jitterMs; + + logger.warn( + `Rate limited (attempt ${attempt + 1}/${maxRetries}). ` + + `Retrying in ${Math.round(delay)}ms...` + ); + + await new Promise((resolve) => setTimeout(resolve, delay)); + } + } + + throw new Error("withRetry: unreachable"); +} + +// Usage: wrap any API call that might be rate limited +app.message(/hello/i, async ({ send }) => { + await withRetry(() => send("Hello!")); +}); + +app.start(3978); +``` + +### Rate-limited proactive broadcast + +**Slack (before):** + +```typescript +import { App } from "@slack/bolt"; + +const app = new App({ + token: process.env.SLACK_BOT_TOKEN!, + signingSecret: process.env.SLACK_SIGNING_SECRET!, +}); + +// Broadcast to all channels — Bolt retries handle 429s +app.command("/broadcast", async ({ ack, command, client }) => { + await ack(); + const channels = await client.conversations.list({ types: "public_channel" }); + + for (const channel of channels.channels ?? []) { + try { + await client.chat.postMessage({ + channel: channel.id!, + text: command.text, + }); + } catch (err: any) { + if (err.data?.error === "ratelimited") { + const retryAfter = parseInt(err.data.response_metadata?.retry_after ?? "1", 10); + await new Promise((r) => setTimeout(r, retryAfter * 1000)); + await client.chat.postMessage({ channel: channel.id!, text: command.text }); + } + } + } +}); +``` + +**Teams (after) — queued broadcast with concurrency control:** + +```typescript +import { App } from "@microsoft/teams.apps"; +import { ConsoleLogger } from "@microsoft/teams.common"; +import PQueue from "p-queue"; + +const logger = new ConsoleLogger("my-bot", { level: "info" }); +const app = new App({ logger }); + +// Store conversation references at install time +const conversationRefs = new Map(); + +app.on("install.add", async ({ activity }) => { + const convId = activity.conversation?.id ?? ""; + conversationRefs.set(convId, { + conversationId: convId, + serviceUrl: (activity as any).serviceUrl, + }); +}); + +// Rate-limited broadcast queue +// Concurrency: 5 simultaneous sends, 200ms between each +const sendQueue = new PQueue({ + concurrency: 5, + interval: 200, + intervalCap: 1, // 1 task per interval per concurrency slot +}); + +app.message(/^\/?broadcast (.+)$/i, async ({ send, activity }) => { + const text = activity.text?.replace(/^\/?broadcast\s+/i, "") ?? ""; + const targets = Array.from(conversationRefs.values()); + + await send(`Broadcasting to ${targets.length} conversations...`); + + let sent = 0; + let failed = 0; + + const promises = targets.map((ref) => + sendQueue.add(async () => { + try { + await withRetry(() => app.send(ref.conversationId, text)); + sent++; + } catch (err) { + failed++; + logger.error(`Failed to send to ${ref.conversationId}:`, err); + } + }) + ); + + await Promise.all(promises); + await send(`Broadcast complete: ${sent} sent, ${failed} failed.`); +}); + +// withRetry from previous pattern +async function withRetry(fn: () => Promise): Promise { + for (let attempt = 0; attempt < 3; attempt++) { + try { + return await fn(); + } catch (err: any) { + const status = err?.statusCode ?? err?.response?.status; + if (status !== 429 || attempt === 2) throw err; + const retryAfter = parseInt(err?.response?.headers?.["retry-after"] ?? "2", 10); + await new Promise((r) => setTimeout(r, retryAfter * 1000 + Math.random() * 500)); + } + } + throw new Error("unreachable"); +} + +app.start(3978); +``` + +### Circuit breaker for downstream services + +```typescript +import CircuitBreaker from "opossum"; + +// Wrap an external API call with a circuit breaker +const fetchUserData = new CircuitBreaker( + async (userId: string) => { + const response = await fetch(`https://api.internal.com/users/${userId}`); + if (!response.ok) throw new Error(`HTTP ${response.status}`); + return response.json(); + }, + { + timeout: 5000, // If the function takes longer than 5s, trigger a failure + errorThresholdPercentage: 50, // Open circuit when 50% of requests fail + resetTimeout: 30_000, // After 30s, try again (half-open) + volumeThreshold: 5, // Minimum 5 requests before evaluating threshold + } +); + +// Circuit events for monitoring +fetchUserData.on("open", () => logger.warn("Circuit OPEN — failing fast")); +fetchUserData.on("halfOpen", () => logger.info("Circuit HALF-OPEN — testing")); +fetchUserData.on("close", () => logger.info("Circuit CLOSED — normal operation")); + +// Usage in a handler +app.message(/^\/?user (.+)$/i, async ({ send, activity }) => { + const userId = activity.text?.match(/user\s+(\S+)/)?.[1] ?? ""; + try { + const user = await fetchUserData.fire(userId); + await send(`User: ${user.name} (${user.email})`); + } catch (err: any) { + if (err.message === "Breaker is open") { + await send("The user service is temporarily unavailable. Please try again later."); + } else { + await send(`Error fetching user: ${err.message}`); + } + } +}); +``` + +### Best practice: retry utility + p-queue broadcast (Y17) + +**Always build a retry utility with exponential backoff and jitter.** Apply it to all outbound API calls. For proactive broadcasts, combine with `p-queue` concurrency control. + +```typescript +// Production retry utility — apply to all outbound calls +async function withRetry(fn: () => Promise, maxRetries = 3): Promise { + for (let attempt = 0; attempt <= maxRetries; attempt++) { + try { + return await fn(); + } catch (err: any) { + if (attempt === maxRetries) throw err; + const retryAfter = err?.response?.headers?.["retry-after"]; + const baseDelay = retryAfter ? parseInt(retryAfter) * 1000 : 1000 * 2 ** attempt; + const jitter = Math.random() * 1000; + await new Promise(r => setTimeout(r, baseDelay + jitter)); + } + } + throw new Error("Unreachable"); +} + +// Proactive broadcast with concurrency control +import PQueue from "p-queue"; + +const broadcastQueue = new PQueue({ concurrency: 5, interval: 200, intervalCap: 1 }); + +async function broadcastToAll( + conversationIds: string[], + message: string +): Promise<{ sent: number; failed: number }> { + let sent = 0, failed = 0; + + const promises = conversationIds.map(convId => + broadcastQueue.add(async () => { + try { + await withRetry(() => app.send(convId, message)); + sent++; + } catch { + failed++; + } + }) + ); + + await Promise.all(promises); + return { sent, failed }; +} +``` + +**Key rules:** +- **Always add jitter.** Without it, multiple bot instances retry simultaneously (thundering herd). +- **Set a max queue depth.** Unbounded queues accumulate thousands of items in memory. +- **Treat 503 the same as 429.** Both are retryable with backoff. + +**Don't:** Retry without jitter, or use Bolt's `retryConfig` and assume it covers Graph API calls (it only covers Slack API calls). + +**Reverse (Teams → Slack):** Configure Bolt's built-in `retryConfig: { retries: 3, factor: 2 }` for Slack API calls. The `p-queue` pattern applies equally for Slack broadcasts. + +### Rate limit comparison table + +| Aspect | Slack | Teams Bot Framework | Teams Graph API | +|---|---|---|---| +| Rate limit scope | Per-method per-token | Per-conversation | Per-app per-tenant | +| Message send limit | ~1/sec per token | ~1/sec per conversation | N/A (use Bot Framework) | +| Throttle response | HTTP 429 + `Retry-After` | HTTP 429 + `Retry-After` | HTTP 429 + `Retry-After` | +| Built-in retry (SDK) | Bolt `retryConfig` | None (manual) | None (manual) | +| Batch API | N/A | N/A | `POST /$batch` (up to 20) | +| Burst limit | ~30/min per token | ~30/min per conversation | Varies by API | + +## pitfalls + +- **No built-in retry in Teams SDK**: Slack Bolt's `retryConfig` automatically retries rate-limited requests. The Teams SDK has no equivalent. You must implement retry logic yourself or use a library wrapper. +- **Per-conversation vs per-token limits**: Slack rate limits are per-method-per-token (global). Teams Bot Framework limits are per-conversation. Sending to 100 different conversations simultaneously is fine; sending 100 messages to the same conversation will be throttled. +- **Graph API and Bot Framework throttling are independent**: A bot can be rate-limited on Graph API calls (user lookups, channel operations) while Bot Framework message sends are fine, or vice versa. Implement retry logic for both independently. +- **Thundering herd on retry**: Without jitter, all rate-limited requests retry at exactly the same time, causing another burst. Always add random jitter to retry delays. +- **Queue depth unbounded**: Using `p-queue` without a size limit can accumulate thousands of pending messages in memory. Set a maximum queue size and reject new items when full (with a user-facing error). +- **Circuit breaker not covering all dependencies**: The circuit breaker should wrap every external dependency (database, third-party API, Graph API) — not just one. A bot with an unprotected dependency can still cascade-fail. +- **Forgetting to handle 503 Service Unavailable**: In addition to 429, Bot Framework may return 503 during outages. Treat 503 the same as 429 (retryable with backoff). + +## references + +- https://learn.microsoft.com/en-us/microsoftteams/platform/bots/how-to/rate-limit +- https://learn.microsoft.com/en-us/graph/throttling +- https://learn.microsoft.com/en-us/graph/json-batching +- https://learn.microsoft.com/en-us/azure/architecture/best-practices/retry-service-specific +- https://learn.microsoft.com/en-us/azure/azure-monitor/app/nodejs +- https://www.npmjs.com/package/p-queue +- https://www.npmjs.com/package/opossum +- https://github.com/microsoft/teams.ts +- https://api.slack.com/docs/rate-limits — Slack rate limits + +## instructions + +Use this expert when adding cross-platform support in either direction for rate limiting and resilience. It covers: Slack Bolt `retryConfig` bridged to Teams manual exponential backoff + jitter, Teams Bot Framework per-conversation rate limits, Graph API per-app throttling, proactive broadcast with send queue concurrency control, circuit breaker pattern with `opossum`, Graph API batch requests, monitoring throttling events, and reverse mapping from custom Teams retry logic back to Bolt's built-in retry configuration. Pair with `../teams/runtime.proactive-messaging-ts.md` for proactive send infrastructure, `../teams/graph.usergraph-appgraph-ts.md` for Graph API patterns, and `scheduling-deferred-send-ts.md` for rate-limited scheduled sends. + +## research + +Deep Research prompt: + +"Write a micro expert for bridging Slack and Teams rate limiting patterns, retry logic, and resilience strategies in either direction. Cover: Bolt retryConfig vs manual exponential backoff + jitter, Teams Bot Framework per-conversation rate limits (1 msg/sec, 30 msg/min), Graph API per-app throttling, proactive broadcast send queues with concurrency control, circuit breaker pattern with opossum, Graph API $batch for reducing call volume, 429/503 retry handling, monitoring, and reverse mapping from Teams retry patterns back to Slack Bolt's built-in retry configuration. Include TypeScript code examples and a comparison table." diff --git a/plugins/microsoft-365-agents-toolkit/skills/teams-app-developer/experts/bridge/rest-only-integration-ts.md b/plugins/microsoft-365-agents-toolkit/skills/teams-app-developer/experts/bridge/rest-only-integration-ts.md new file mode 100644 index 0000000..bfd40be --- /dev/null +++ b/plugins/microsoft-365-agents-toolkit/skills/teams-app-developer/experts/bridge/rest-only-integration-ts.md @@ -0,0 +1,150 @@ +# rest-only-integration-ts + +## purpose + +Raw HTTP integration patterns for Teams and Slack without native SDKs — Bot Framework REST API for Teams, Slack Events API + Web API for Slack. For Java, C#, Go, or any language that lacks an official Bolt or Teams SDK. + +## rules + +1. **Use the Bot Framework REST API for Teams when no SDK is available.** The REST API is language-agnostic. Authenticate via Azure AD OAuth2 client credentials, then POST activities to the Bot Connector service URL. +2. **Use the Slack Events API + Web API for Slack when no Bolt SDK is available.** Receive events via HTTP POST webhooks (with signature verification), respond via `chat.postMessage` and other Web API methods. +3. **Verify Slack request signatures manually.** Compute `HMAC-SHA256` of `v0:{timestamp}:{request_body}` using your signing secret. Compare against the `X-Slack-Signature` header. Reject if timestamp is older than 5 minutes. +4. **Verify Teams JWT tokens manually.** Validate the `Authorization: Bearer ` header against Azure AD's OpenID configuration. Check `iss`, `aud` (your app ID), and token expiration. Use your platform's JWT library. +5. **Acquire Bot Framework tokens via Azure AD.** POST to `https://login.microsoftonline.com/{tenantId}/oauth2/v2.0/token` with `client_id`, `client_secret`, and `scope=https://api.botframework.com/.default`. +6. **Send Teams messages via the Bot Connector API.** POST to `{serviceUrl}/v3/conversations/{conversationId}/activities` with the activity JSON and a `Bearer` token. The `serviceUrl` comes from the inbound activity. +7. **Send Slack messages via the Web API.** POST to `https://slack.com/api/chat.postMessage` with `Authorization: Bearer xoxb-...` and a JSON body containing `channel`, `text`, and optionally `blocks`. +8. **Acknowledge Slack events within 3 seconds.** Return HTTP 200 immediately, then process async. For interactions (actions, commands, shortcuts), return a JSON body or empty 200 to acknowledge. +9. **Handle the Slack URL verification challenge.** When Slack sends `{ type: "url_verification", challenge: "..." }`, respond with `{ challenge: "..." }` and HTTP 200. This only happens once during setup. +10. **Return HTTP 200/201 for Teams webhook POSTs.** The Bot Framework expects a 200 response. For invoke activities, return a JSON body with `{ status: 200, body: ... }`. +11. **Store the `serviceUrl` from Teams activities.** Each inbound activity includes a `serviceUrl` that may change. Use it for subsequent API calls to that conversation. Cache per conversation. +12. **Use `response_url` for Slack interaction responses.** Actions, commands, and shortcuts include a `response_url`. POST to it within 30 minutes with `{ text, response_type }` for follow-up messages without needing the Web API. + +## patterns + +### Slack signature verification (pseudocode, any language) + +``` +function verifySlackSignature(signingSecret, timestamp, body, signature): + if abs(now() - timestamp) > 300: // 5 minutes + return false + basestring = "v0:" + timestamp + ":" + body + computed = "v0=" + hmac_sha256(signingSecret, basestring) + return timingSafeCompare(computed, signature) + +// HTTP handler: +timestamp = request.headers["X-Slack-Request-Timestamp"] +signature = request.headers["X-Slack-Signature"] +if not verifySlackSignature(SECRET, timestamp, rawBody, signature): + return 401 +``` + +### Teams token acquisition (HTTP, any language) + +``` +POST https://login.microsoftonline.com/{tenantId}/oauth2/v2.0/token +Content-Type: application/x-www-form-urlencoded + +client_id={appId} +&client_secret={appPassword} +&scope=https://api.botframework.com/.default +&grant_type=client_credentials + +Response: { "access_token": "eyJ...", "expires_in": 3600 } +``` + +### Send a Teams message (HTTP, any language) + +``` +POST {serviceUrl}/v3/conversations/{conversationId}/activities +Authorization: Bearer {access_token} +Content-Type: application/json + +{ + "type": "message", + "text": "Hello from a REST client!", + "from": { "id": "{botAppId}", "name": "My Bot" } +} +``` + +### Send a Slack message (HTTP, any language) + +``` +POST https://slack.com/api/chat.postMessage +Authorization: Bearer xoxb-your-token +Content-Type: application/json + +{ + "channel": "C123ABC", + "text": "Hello from a REST client!", + "blocks": [ + { + "type": "section", + "text": { "type": "mrkdwn", "text": "Hello from a REST client!" } + } + ] +} +``` + +### Slack event webhook handler (pseudocode) + +``` +function handleSlackEvent(request): + verifySignature(request) + body = parseJSON(request.body) + + if body.type == "url_verification": + return { challenge: body.challenge } + + if body.type == "event_callback": + event = body.event + // Process event async... + return 200 // acknowledge immediately + + if body.type == "interactive": + // action, shortcut, or view_submission + return 200 // ack, then use response_url for follow-up +``` + +### Teams JWT validation (pseudocode) + +``` +function validateTeamsJWT(authHeader, appId): + token = authHeader.replace("Bearer ", "") + // Fetch keys from https://login.botframework.com/v1/.well-known/openidconfiguration + claims = jwt_verify(token, publicKeys) + assert claims.aud == appId + assert claims.iss starts with "https://api.botframework.com" + assert claims.exp > now() + return claims +``` + +## pitfalls + +- **Slack signature uses raw body, not parsed JSON.** You must verify against the exact bytes received, not a re-serialized JSON string. Many frameworks parse the body before your handler — use middleware to capture the raw body. +- **Teams `serviceUrl` varies by region.** Don't hardcode it. The URL may be `https://smba.trafficmanager.net/...` or `https://emea.ng.msg.teams.microsoft.com/...` depending on the tenant's region. +- **Bot Framework tokens expire after 1 hour.** Cache the token and refresh before expiry. Don't acquire a new token for every outbound message — this adds latency and hits rate limits. +- **Slack's `response_url` expires after 30 minutes.** If you need to update a message later, use `chat.update` with the message `ts` instead. +- **Teams proactive messaging requires a conversation reference.** You can't just POST to a user ID — you need the `conversationId` and `serviceUrl` from a previous inbound activity. Store these on first contact. +- **No Adaptive Card support via REST without the schema.** You must construct the full Adaptive Card JSON yourself. Use the Adaptive Card Designer (https://adaptivecards.io/designer/) to prototype, then embed the JSON in your API calls. +- **Slack interactive payload is form-encoded, not JSON.** Actions, shortcuts, and view submissions arrive as `application/x-www-form-urlencoded` with a `payload` field containing JSON. Parse the `payload` field, not the raw body. + +## references + +- Bot Framework REST API: https://learn.microsoft.com/en-us/azure/bot-service/rest-api/bot-framework-rest-connector-api-reference +- Slack Events API: https://api.slack.com/events-api +- Slack Web API: https://api.slack.com/web +- Slack request verification: https://api.slack.com/authentication/verifying-requests-from-slack +- Azure AD token endpoint: https://learn.microsoft.com/en-us/azure/active-directory/develop/v2-oauth2-client-creds-grant-flow +- Adaptive Card Designer: https://adaptivecards.io/designer/ + +## instructions + +Use this expert when integrating with Teams or Slack from a language that lacks a native SDK (Java for Teams, C# for Slack, Go, Ruby, etc.), or when building a lightweight integration that doesn't warrant a full SDK dependency. The patterns use pseudocode that's translatable to any language. + +Pair with: `cross-platform-architecture-ts.md` (if also using TS for one platform), `../teams/runtime.app-init-ts.md` (for TS Teams SDK comparison), `../slack/runtime.bolt-foundations-ts.md` (for TS Slack SDK comparison). + +## research + +Deep Research prompt: + +"Document raw HTTP integration patterns for Microsoft Teams Bot Framework and Slack without native SDKs. Cover: Bot Framework REST Connector API (POST activities, GET conversations), Azure AD client credentials OAuth2 token acquisition, JWT validation for inbound webhooks, Slack Events API webhook setup (URL verification challenge, event_callback processing), Slack Web API methods (chat.postMessage, chat.update, views.open), Slack request signature verification (HMAC-SHA256, timing-safe comparison, timestamp validation), response_url for interaction follow-ups, Adaptive Card JSON construction for REST, Block Kit JSON construction for REST, serviceUrl caching for Teams, and rate limiting considerations." diff --git a/plugins/microsoft-365-agents-toolkit/skills/teams-app-developer/experts/bridge/scheduling-deferred-send-ts.md b/plugins/microsoft-365-agents-toolkit/skills/teams-app-developer/experts/bridge/scheduling-deferred-send-ts.md new file mode 100644 index 0000000..386a92f --- /dev/null +++ b/plugins/microsoft-365-agents-toolkit/skills/teams-app-developer/experts/bridge/scheduling-deferred-send-ts.md @@ -0,0 +1,336 @@ +# scheduling-deferred-send-ts + +## purpose + +Bridges Slack scheduling (chat.scheduleMessage, reminders) and Teams deferred delivery patterns for cross-platform bots targeting Slack, Teams, or both. + +## rules + +1. **Slack `chat.scheduleMessage` has NO Teams equivalent.** Teams has no built-in scheduled message API. Replace with: store the message + target time in persistent storage, then use a timer mechanism to send proactively at the scheduled time via `app.send(conversationId, message)`. [learn.microsoft.com -- Proactive messages](https://learn.microsoft.com/en-us/microsoftteams/platform/bots/how-to/conversations/send-proactive-messages) +2. **Slack `chat.deleteScheduledMessage` → delete from your own storage/queue.** Since scheduled messages are self-managed in Teams, cancellation is simply removing the pending item from your storage (database row, queue message, cron job). No platform API call needed. [github.com/microsoft/teams.ts](https://github.com/microsoft/teams.ts) +3. **Slack `reminders.add` → persistent storage + background poll + proactive send.** Slack reminders are platform-managed with DM delivery. In Teams, the bot must: (a) store the reminder with target user/time, (b) poll or use a timer to detect due reminders, (c) send a proactive message to the user's 1:1 chat. [learn.microsoft.com -- Proactive messages](https://learn.microsoft.com/en-us/microsoftteams/platform/bots/how-to/conversations/send-proactive-messages) +4. **In-process timers (`node-cron`, `setTimeout`) are for development only.** `node-cron` or `setTimeout` work for local dev and single-instance deployments. They are NOT durable — a process restart loses all scheduled items. Never use in-process timers for production scheduled messages. [npmjs.com/node-cron](https://www.npmjs.com/package/node-cron) +5. **Azure Functions timer trigger provides durable serverless scheduling.** Create a timer-triggered function that polls your database for due messages and sends them proactively. The CRON expression configures frequency (e.g., `"0 */1 * * * *"` for every minute). Azure manages the timer lifecycle across restarts. [learn.microsoft.com -- Timer trigger](https://learn.microsoft.com/en-us/azure/azure-functions/functions-bindings-timer) +6. **Azure Queue Storage with visibility timeout enables exact-time scheduling.** Enqueue a message with `visibilityTimeout` set to the delay duration. The message becomes visible at the target time, triggering a queue-triggered function that sends the proactive message. Maximum visibility timeout is 7 days. [learn.microsoft.com -- Queue trigger](https://learn.microsoft.com/en-us/azure/azure-functions/functions-bindings-storage-queue-trigger) +7. **Azure Service Bus scheduled messages support exact-time delivery.** `ServiceBusSender.scheduleMessages(message, scheduledEnqueueTimeUtc)` enqueues with a future delivery time. No polling needed — Service Bus delivers at the exact time. Supports cancellation via `cancelScheduledMessage(sequenceNumber)`. Best for high-volume scheduled sends. [learn.microsoft.com -- Service Bus scheduling](https://learn.microsoft.com/en-us/azure/service-bus-messaging/message-sequencing#scheduled-messages) +8. **Power Automate "Recurrence" trigger is a no-code alternative.** For simple recurring messages (daily standup reminder, weekly digest), a Power Automate flow with a Recurrence trigger can send messages via the bot's webhook or Graph API without code. Good for business users managing their own schedules. [learn.microsoft.com -- Power Automate Recurrence](https://learn.microsoft.com/en-us/power-automate/triggers-introduction#recurrence-trigger) +9. **Store conversation references at install time for proactive messaging.** All scheduled/reminder sends require a valid conversation reference (including `serviceUrl`). Capture and persist the reference in the `install.add` handler. Without it, the bot cannot send proactive messages at scheduled time. [learn.microsoft.com -- Conversation reference](https://learn.microsoft.com/en-us/microsoftteams/platform/bots/how-to/conversations/send-proactive-messages#get-the-conversation-reference) +10. **Rate limiting applies to bulk scheduled sends.** Teams limits bots to ~1 message/second per conversation and ~30 messages/minute per conversation. If many scheduled messages are due at the same time (e.g., "send daily digest to 500 users at 9 AM"), implement a send queue with concurrency control and staggered delivery. [learn.microsoft.com -- Rate limits](https://learn.microsoft.com/en-us/microsoftteams/platform/bots/how-to/rate-limit) +11. **Reverse direction (Teams → Slack):** For Teams → Slack, this is simpler — Slack has native `chat.scheduleMessage` and `reminders.add` APIs. Timer-based infrastructure (Azure Functions, Queue Storage, Service Bus) can be replaced with direct Slack API calls. Map proactive send patterns to `chat.scheduleMessage` with a `post_at` Unix timestamp. Map Power Automate recurrence flows to Slack Workflow Builder scheduled triggers or `reminders.add` for user-facing reminders. + +## patterns + +### node-cron + proactive messaging (development / single-instance) + +**Slack (before):** + +```typescript +import { App } from "@slack/bolt"; + +const app = new App({ + token: process.env.SLACK_BOT_TOKEN!, + signingSecret: process.env.SLACK_SIGNING_SECRET!, +}); + +// Schedule a message for 30 minutes from now +app.command("/remind", async ({ ack, command, client }) => { + await ack(); + const postAt = Math.floor(Date.now() / 1000) + 30 * 60; + const result = await client.chat.scheduleMessage({ + channel: command.channel_id, + text: command.text, + post_at: postAt, + }); + await client.chat.postMessage({ + channel: command.channel_id, + text: `Reminder set! ID: ${result.scheduled_message_id}`, + }); +}); + +// Cancel a scheduled message +app.command("/cancel-remind", async ({ ack, command, client }) => { + await ack(); + await client.chat.deleteScheduledMessage({ + channel: command.channel_id, + scheduled_message_id: command.text.trim(), + }); + await client.chat.postMessage({ + channel: command.channel_id, + text: "Reminder cancelled.", + }); +}); +``` + +**Teams (after) — node-cron for dev:** + +```typescript +import { App } from "@microsoft/teams.apps"; +import { ConsoleLogger } from "@microsoft/teams.common"; +import cron from "node-cron"; + +const app = new App({ + logger: new ConsoleLogger("my-bot", { level: "info" }), +}); + +// In-memory store (replace with database in production) +const scheduledMessages = new Map(); + +// Store conversation references at install time +const conversationRefs = new Map(); + +app.on("install.add", async ({ activity }) => { + const convId = activity.conversation?.id ?? ""; + conversationRefs.set(convId, { + conversationId: convId, + serviceUrl: (activity as any).serviceUrl, + }); +}); + +// Schedule a reminder +app.message(/^\/?remind (.+)$/i, async ({ send, activity }) => { + const text = activity.text?.replace(/^\/?remind\s+/i, "") ?? ""; + const convId = activity.conversation?.id ?? ""; + const id = `rem_${Date.now()}`; + const sendAt = new Date(Date.now() + 30 * 60_000); // 30 min from now + + scheduledMessages.set(id, { conversationId: convId, text, sendAt }); + + // Schedule with node-cron (NOT durable — dev only) + const task = cron.schedule( + cronFromDate(sendAt), + async () => { + await app.send(convId, text); + scheduledMessages.delete(id); + task.stop(); + }, + { scheduled: true } + ); + + scheduledMessages.get(id)!.cronTask = task; + await send(`Reminder set for ${sendAt.toISOString()}. ID: ${id}`); +}); + +// Cancel a reminder +app.message(/^\/?cancel-remind (\S+)$/i, async ({ send, activity }) => { + const id = activity.text?.match(/cancel-remind\s+(\S+)/i)?.[1] ?? ""; + const item = scheduledMessages.get(id); + if (item) { + item.cronTask?.stop(); + scheduledMessages.delete(id); + await send("Reminder cancelled."); + } else { + await send("Reminder not found."); + } +}); + +function cronFromDate(date: Date): string { + return `${date.getMinutes()} ${date.getHours()} ${date.getDate()} ${date.getMonth() + 1} *`; +} + +app.start(3978); +``` + +### Azure Functions timer + Cosmos DB (production) + +**Timer-triggered function (polls for due messages):** + +```typescript +// src/functions/sendScheduledMessages.ts +import { app as azFunc, InvocationContext, Timer } from "@azure/functions"; +import { CosmosClient } from "@azure/cosmos"; + +const cosmos = new CosmosClient(process.env.COSMOS_CONNECTION!); +const container = cosmos.database("botdb").container("scheduled-messages"); + +// Runs every minute — checks for due scheduled messages +azFunc.timer("sendScheduledMessages", { + schedule: "0 */1 * * * *", // every minute + handler: async (timer: Timer, context: InvocationContext) => { + const now = new Date().toISOString(); + + // Query for messages due now or overdue + const { resources: dueMessages } = await container.items + .query({ + query: "SELECT * FROM c WHERE c.sendAt <= @now AND c.status = 'pending'", + parameters: [{ name: "@now", value: now }], + }) + .fetchAll(); + + for (const msg of dueMessages) { + try { + // Send proactive message via Teams bot + // In practice, import your Teams app instance and call app.send() + await sendProactiveMessage(msg.conversationId, msg.text, msg.serviceUrl); + + // Mark as sent + await container.item(msg.id, msg.conversationId).replace({ + ...msg, + status: "sent", + sentAt: new Date().toISOString(), + }); + } catch (err) { + context.error(`Failed to send scheduled message ${msg.id}:`, err); + // Mark as failed for retry + await container.item(msg.id, msg.conversationId).replace({ + ...msg, + status: "failed", + error: String(err), + }); + } + } + + context.log(`Processed ${dueMessages.length} scheduled messages.`); + }, +}); + +async function sendProactiveMessage(conversationId: string, text: string, serviceUrl: string) { + // Use Bot Framework REST API or your Teams app instance + // POST to {serviceUrl}/v3/conversations/{conversationId}/activities + const response = await fetch(`${serviceUrl}/v3/conversations/${conversationId}/activities`, { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${await getBotToken()}`, + }, + body: JSON.stringify({ + type: "message", + text, + }), + }); + if (!response.ok) throw new Error(`HTTP ${response.status}`); +} + +async function getBotToken(): Promise { + // Obtain token via client credentials flow + return "..."; +} +``` + +**Scheduling endpoint (called from the bot handler):** + +```typescript +// In your bot handler — schedule a message +app.message(/^\/?remind (.+)$/i, async ({ send, activity }) => { + const text = activity.text?.replace(/^\/?remind\s+/i, "") ?? ""; + const convId = activity.conversation?.id ?? ""; + const sendAt = new Date(Date.now() + 30 * 60_000); + + // Persist to Cosmos DB — timer function will pick it up + await container.items.create({ + id: `rem_${Date.now()}`, + conversationId: convId, + serviceUrl: (activity as any).serviceUrl, + text, + sendAt: sendAt.toISOString(), + status: "pending", + createdBy: activity.from?.aadObjectId, + }); + + await send(`Reminder set for ${sendAt.toISOString()}.`); +}); +``` + +### Azure Service Bus scheduled messages (R7 — production, exact-time) + +The most production-ready approach for exact-time delivery with native cancellation support. + +```typescript +import { ServiceBusClient } from "@azure/service-bus"; + +const sbClient = new ServiceBusClient(process.env.SERVICEBUS_CONNECTION!); +const sender = sbClient.createSender("scheduled-messages"); + +// Schedule a message for exact-time delivery +async function scheduleMessage( + conversationId: string, text: string, sendAt: Date +): Promise { + const [sequenceNumber] = await sender.scheduleMessages( + { body: { conversationId, text } }, + sendAt + ); + return sequenceNumber; // store this for cancellation +} + +// Cancel a scheduled message +async function cancelScheduled(sequenceNumber: Long): Promise { + await sender.cancelScheduledMessages(sequenceNumber); +} + +// Receiver (runs as a separate process or Azure Function) +const receiver = sbClient.createReceiver("scheduled-messages"); +receiver.subscribe({ + processMessage: async (msg) => { + const { conversationId, text } = msg.body; + await app.send(conversationId, text); + }, + processError: async (err) => console.error(err), +}); +``` + +**Bot handler integration:** + +```typescript +app.message(/^\/?schedule (.+) at (.+)$/i, async ({ send, activity }) => { + const match = activity.text?.match(/schedule (.+) at (.+)/i); + const text = match?.[1] ?? ""; + const sendAt = new Date(match?.[2] ?? ""); + const convId = activity.conversation?.id ?? ""; + + const seqNum = await scheduleMessage(convId, text, sendAt); + // Store seqNum in database for cancellation + await send(`Scheduled for ${sendAt.toISOString()}. Cancel ID: ${seqNum}`); +}); +``` + +**When to use Service Bus vs other approaches:** +- **Service Bus:** High-volume, exact-time delivery, native cancellation. Best overall. +- **Queue Storage:** Simple delays under 7 days. Cheaper. No native cancellation. +- **Cosmos DB + Timer:** Unlimited delay. Minute-level precision. Most flexible. + +**Reverse (Teams → Slack):** Use `chat.scheduleMessage({ channel, text, post_at })` natively. + +### Scheduling approach comparison + +| Approach | Durability | Precision | Max Delay | Cancellation | Best For | +|---|---|---|---|---|---| +| `setTimeout` / `node-cron` | None (lost on restart) | ~1 sec | Unlimited | In-memory | Dev only | +| Azure Functions timer | Durable | ~1 min (poll interval) | Unlimited | Delete DB row | General production | +| Queue Storage visibility timeout | Durable | ~seconds | 7 days | Delete queue message | Short delays, simple | +| Service Bus scheduled messages | Durable | ~seconds | Unlimited | `cancelScheduledMessage()` | High-volume, exact-time | +| Power Automate Recurrence | Durable | ~1 min | Unlimited | Disable flow | No-code recurring | + +## pitfalls + +- **In-process timers are not durable**: `setTimeout` and `node-cron` lose all scheduled items on process restart, deployment, or scaling event. Never use for production. This is the #1 migration failure — developers assume their timer survives restarts like Slack's `scheduleMessage`. +- **Missing conversation reference at send time**: Proactive messaging requires a valid `serviceUrl` and `conversationId` stored at install time. If the bot hasn't stored these, it cannot send scheduled messages. Always persist conversation references in the `install.add` handler. +- **Rate limiting on bulk sends**: Sending 500 scheduled messages at 9:00 AM will hit the ~1 msg/sec/conversation limit. Implement a staggered send queue with delays between messages. Service Bus or Queue Storage with staggered visibility timeouts helps distribute load. +- **Timer function CRON precision**: Azure Functions timer triggers run at CRON intervals (e.g., every minute), not at exact timestamps. A message scheduled for 9:00:30 may not send until 9:01:00. For higher precision, use Queue Storage visibility timeout or Service Bus scheduled messages. +- **Queue Storage 7-day visibility timeout limit**: Messages with visibility timeout > 7 days silently default to 7 days. For long-horizon scheduling (weeks/months), use a database + timer function approach instead. +- **Power Automate requires premium license for custom connectors**: Sending via the bot's API requires a custom connector or HTTP action in Power Automate, which may need a premium license depending on the organization's plan. + +## references + +- https://learn.microsoft.com/en-us/microsoftteams/platform/bots/how-to/conversations/send-proactive-messages +- https://learn.microsoft.com/en-us/azure/azure-functions/functions-bindings-timer +- https://learn.microsoft.com/en-us/azure/azure-functions/functions-bindings-storage-queue-trigger +- https://learn.microsoft.com/en-us/azure/service-bus-messaging/message-sequencing#scheduled-messages +- https://learn.microsoft.com/en-us/microsoftteams/platform/bots/how-to/rate-limit +- https://learn.microsoft.com/en-us/power-automate/triggers-introduction +- https://github.com/microsoft/teams.ts +- https://api.slack.com/methods/chat.scheduleMessage — Slack scheduled messages +- https://api.slack.com/methods/reminders.add — Slack reminders + +## instructions + +Use this expert when adding cross-platform support in either direction for scheduled messages, reminders, and deferred delivery. It covers: Slack `chat.scheduleMessage` bridged to Teams timer + proactive send, `reminders.add` bridged to persistent storage patterns, in-process timers (dev), Azure Functions timer triggers (production), Queue Storage visibility timeout, Service Bus scheduled messages, Power Automate Recurrence, rate limiting for bulk sends, conversation reference storage requirements, and reverse mapping from Teams deferred patterns back to Slack native scheduling APIs. Pair with `../teams/runtime.proactive-messaging-ts.md` for proactive messaging infrastructure, `../teams/state.storage-patterns-ts.md` for persisting scheduled items, and `slack-interactive-responses-to-teams-ts.md` for deferred response patterns. + +## research + +Deep Research prompt: + +"Write a micro expert for bridging Slack scheduled messages (chat.scheduleMessage, chat.deleteScheduledMessage) and reminders (reminders.add) with Microsoft Teams deferred delivery patterns in either direction. Cover: proactive messaging with stored conversation references, in-process timers (node-cron/setTimeout) for dev, Azure Functions timer trigger for production, Queue Storage visibility timeout, Service Bus scheduled messages, Power Automate Recurrence, rate limiting for bulk sends, cancellation patterns, and reverse mapping from Teams deferred infrastructure back to Slack native scheduling APIs. Include TypeScript code examples and a comparison table." diff --git a/plugins/microsoft-365-agents-toolkit/skills/teams-app-developer/experts/bridge/shortcuts-extensions-ts.md b/plugins/microsoft-365-agents-toolkit/skills/teams-app-developer/experts/bridge/shortcuts-extensions-ts.md new file mode 100644 index 0000000..0e611b6 --- /dev/null +++ b/plugins/microsoft-365-agents-toolkit/skills/teams-app-developer/experts/bridge/shortcuts-extensions-ts.md @@ -0,0 +1,358 @@ +# shortcuts-extensions-ts + +## purpose + +Bridges Slack shortcuts (global and message) and Teams message extensions / compose extensions for cross-platform bots targeting Slack, Teams, or both. + +## rules + +1. **Slack global shortcuts → Teams action-based compose extensions with `context: ['compose', 'commandBox']`.** Slack global shortcuts appear in the lightning bolt menu and don't reference a specific message. In Teams, the equivalent is a compose extension with `fetchTask: true` and action context targeting the compose box and command bar. [learn.microsoft.com -- Action-based extensions](https://learn.microsoft.com/en-us/microsoftteams/platform/messaging-extensions/how-to/action-commands/define-action-command) +2. **Slack message shortcuts → Teams action-based extensions with `context: ['message']`.** Slack message shortcuts appear in the message context menu (⋮ → More actions). In Teams, action-based extensions with `context: ['message']` appear in the message overflow menu (... → More actions). The target message content is available in the invoke payload. [learn.microsoft.com -- Message context](https://learn.microsoft.com/en-us/microsoftteams/platform/messaging-extensions/how-to/action-commands/define-action-command#choose-action-command-invoke-locations) +3. **Slack `trigger_id` + `views.open()` → Teams `fetchTask: true` + task module.** Slack shortcuts use the `trigger_id` to open a modal. Teams action-based extensions use `fetchTask: true` in the manifest, which causes Teams to invoke the bot's `message.ext.open` handler to fetch the task module (dialog) content. No trigger_id needed. [learn.microsoft.com -- Task module from extension](https://learn.microsoft.com/en-us/microsoftteams/platform/messaging-extensions/how-to/action-commands/create-task-module) +4. **Slack `shortcut.message` (target message in message shortcuts) → Teams `activity.value.messagePayload`.** When a message shortcut is invoked in Slack, the message object is in `shortcut.message`. In Teams, the message that was acted upon is in `activity.value.messagePayload` with `id`, `body.content`, `from`, `createdDateTime`, and `attachments`. [learn.microsoft.com -- Message payload](https://learn.microsoft.com/en-us/microsoftteams/platform/messaging-extensions/how-to/action-commands/define-action-command#payload-activity-properties-when-invoked-from-a-message) +5. **Manifest `composeExtensions[].commands[]` with `type: "action"` is required.** Unlike Slack where shortcuts are configured in the app dashboard, Teams requires each action command to be declared in the manifest JSON with its title, description, parameters, and context array. Without this, the extension never appears. [learn.microsoft.com -- Manifest schema](https://learn.microsoft.com/en-us/microsoftteams/platform/resources/schema/manifest-schema#composeextensionscommands) +6. **Slack `app.shortcut('callback_id')` → Teams handler routing via `activity.value.commandId`.** Slack routes shortcuts by `callback_id`. Teams invokes the same `message.ext.open` handler for all action commands — differentiate by checking `activity.value.commandId` against the command `id` in the manifest. [learn.microsoft.com -- Handle action](https://learn.microsoft.com/en-us/microsoftteams/platform/messaging-extensions/how-to/action-commands/respond-to-task-module-submit) +7. **Task module response replaces Slack modal view return.** Slack's `views.open()` returns a view object with blocks. Teams' `message.ext.open` handler returns a task module response containing either an Adaptive Card or an iframe URL. The Adaptive Card path is closest to Slack's modal behavior. [learn.microsoft.com -- Task modules](https://learn.microsoft.com/en-us/microsoftteams/platform/task-modules-and-cards/what-are-task-modules) +8. **Slack `view_submission` → Teams `message.ext.submit`.** When the user submits the task module form, Teams invokes the `message.ext.submit` handler (or `composeExtension/submitAction` activity). The form data is in `activity.value.data`. The handler can return a card to insert into the compose box, send a message, or show another task module. [learn.microsoft.com -- Handle submit](https://learn.microsoft.com/en-us/microsoftteams/platform/messaging-extensions/how-to/action-commands/respond-to-task-module-submit) +9. **No "fire and forget" shortcuts in Teams.** Slack global shortcuts can trigger background actions without showing a modal (just `ack()` + do work). Teams action-based extensions always show a task module if `fetchTask: true`. To mimic fire-and-forget, return a minimal confirmation card from the task module and process in the background. [learn.microsoft.com -- Action commands](https://learn.microsoft.com/en-us/microsoftteams/platform/messaging-extensions/how-to/action-commands/define-action-command) +10. **Teams action extensions can insert cards into the compose box.** Slack shortcuts post messages via `say()` or `respond()`. Teams action extensions can return a card that gets inserted into the user's compose box for them to review and send. This is a UX improvement — the user controls when the message is posted. Return `{ composeExtension: { type: 'result', attachments: [...] } }` from the submit handler. [learn.microsoft.com -- Respond to submit](https://learn.microsoft.com/en-us/microsoftteams/platform/messaging-extensions/how-to/action-commands/respond-to-task-module-submit#respond-with-an-adaptive-card-message-sent-from-a-bot) +11. **Reverse direction (Teams → Slack):** For Teams → Slack, map compose extensions to `app.shortcut` with `global_shortcut` or `message_shortcut` type. Action-based extensions with `context: ['compose', 'commandBox']` map to Slack global shortcuts; extensions with `context: ['message']` map to Slack message shortcuts. Task module forms become Slack modals opened via `views.open()` with a `trigger_id`. The `message.ext.submit` handler maps to a Slack `view_submission` handler. + +## patterns + +### Message shortcut → action-based message extension + +**Slack (before):** + +```typescript +import { App } from "@slack/bolt"; + +const app = new App({ + token: process.env.SLACK_BOT_TOKEN!, + signingSecret: process.env.SLACK_SIGNING_SECRET!, +}); + +// Message shortcut — appears in message context menu +app.shortcut("create_ticket_from_message", async ({ ack, shortcut, client }) => { + await ack(); + + const message = (shortcut as any).message; + await client.views.open({ + trigger_id: shortcut.trigger_id, + view: { + type: "modal", + callback_id: "ticket_from_message", + title: { type: "plain_text", text: "Create Ticket" }, + submit: { type: "plain_text", text: "Create" }, + blocks: [ + { + type: "input", + block_id: "title_block", + label: { type: "plain_text", text: "Ticket Title" }, + element: { + type: "plain_text_input", + action_id: "title_input", + initial_value: message.text?.substring(0, 100) ?? "", + }, + }, + { + type: "input", + block_id: "priority_block", + label: { type: "plain_text", text: "Priority" }, + element: { + type: "static_select", + action_id: "priority_select", + options: [ + { text: { type: "plain_text", text: "High" }, value: "high" }, + { text: { type: "plain_text", text: "Medium" }, value: "medium" }, + { text: { type: "plain_text", text: "Low" }, value: "low" }, + ], + }, + }, + ], + private_metadata: JSON.stringify({ + channel: message.channel, + messageTs: message.ts, + }), + }, + }); +}); + +app.view("ticket_from_message", async ({ ack, view, client }) => { + await ack(); + const title = view.state.values.title_block.title_input.value!; + const priority = view.state.values.priority_block.priority_select.selected_option?.value; + const meta = JSON.parse(view.private_metadata); + await client.chat.postMessage({ + channel: meta.channel, + text: `Ticket created: *${title}* [${priority}]`, + thread_ts: meta.messageTs, + }); +}); +``` + +**Teams (after):** + +```typescript +import { App } from "@microsoft/teams.apps"; +import { ConsoleLogger } from "@microsoft/teams.common"; + +const app = new App({ + logger: new ConsoleLogger("my-bot", { level: "info" }), +}); + +// message.ext.open — returns task module (replaces views.open with trigger_id) +app.on("message.ext.open" as any, async ({ activity }) => { + const commandId = activity.value?.commandId; + + if (commandId === "createTicketFromMessage") { + // Target message content (replaces shortcut.message) + const messagePayload = activity.value?.messagePayload; + const messageText = messagePayload?.body?.content ?? ""; + + return { + status: 200, + body: { + task: { + type: "continue", + value: { + title: "Create Ticket", + card: { + contentType: "application/vnd.microsoft.card.adaptive", + content: { + type: "AdaptiveCard", + version: "1.5", + body: [ + { + type: "Input.Text", + id: "ticketTitle", + label: "Ticket Title", + value: messageText.substring(0, 100), + isRequired: true, + }, + { + type: "Input.ChoiceSet", + id: "priority", + label: "Priority", + value: "medium", + choices: [ + { title: "High", value: "high" }, + { title: "Medium", value: "medium" }, + { title: "Low", value: "low" }, + ], + }, + ], + actions: [{ + type: "Action.Submit", + title: "Create", + }], + }, + }, + }, + }, + }, + }; + } +}); + +// message.ext.submit — handle form submission (replaces app.view handler) +app.on("message.ext.submit" as any, async ({ activity, send }) => { + const data = activity.value?.data; + if (data) { + const title = data.ticketTitle; + const priority = data.priority; + + // Send confirmation to the conversation + await send(`Ticket created: **${title}** [${priority}]`); + } + return { status: 200, body: {} }; +}); + +app.start(3978); +``` + +**Manifest for the message action extension:** + +```json +{ + "composeExtensions": [ + { + "botId": "${{BOT_ID}}", + "commands": [ + { + "id": "createTicketFromMessage", + "type": "action", + "title": "Create Ticket", + "description": "Create a ticket from this message", + "context": ["message"], + "fetchTask": true + } + ] + } + ] +} +``` + +### Global shortcut → compose extension + +**Slack (before):** + +```typescript +import { App } from "@slack/bolt"; + +const app = new App({ + token: process.env.SLACK_BOT_TOKEN!, + signingSecret: process.env.SLACK_SIGNING_SECRET!, +}); + +// Global shortcut — appears in the ⚡ menu +app.shortcut("quick_note", async ({ ack, shortcut, client }) => { + await ack(); + await client.views.open({ + trigger_id: shortcut.trigger_id, + view: { + type: "modal", + callback_id: "quick_note_modal", + title: { type: "plain_text", text: "Quick Note" }, + submit: { type: "plain_text", text: "Save" }, + blocks: [ + { + type: "input", + block_id: "note_block", + label: { type: "plain_text", text: "Note" }, + element: { + type: "plain_text_input", + action_id: "note_input", + multiline: true, + }, + }, + ], + }, + }); +}); + +app.view("quick_note_modal", async ({ ack, view }) => { + const note = view.state.values.note_block.note_input.value!; + await ack(); + await saveNote(note); +}); +``` + +**Teams (after):** + +```typescript +import { App } from "@microsoft/teams.apps"; +import { ConsoleLogger } from "@microsoft/teams.common"; + +const app = new App({ + logger: new ConsoleLogger("my-bot", { level: "info" }), +}); + +app.on("message.ext.open" as any, async ({ activity }) => { + if (activity.value?.commandId === "quickNote") { + return { + status: 200, + body: { + task: { + type: "continue", + value: { + title: "Quick Note", + card: { + contentType: "application/vnd.microsoft.card.adaptive", + content: { + type: "AdaptiveCard", + version: "1.5", + body: [{ + type: "Input.Text", + id: "noteText", + label: "Note", + isMultiline: true, + isRequired: true, + }], + actions: [{ type: "Action.Submit", title: "Save" }], + }, + }, + }, + }, + }, + }; + } +}); + +app.on("message.ext.submit" as any, async ({ activity }) => { + const note = activity.value?.data?.noteText; + if (note) { + await saveNote(note); + } + // Return empty to close the task module + return { status: 200, body: {} }; +}); + +async function saveNote(note: string) { /* persist note */ } + +app.start(3978); +``` + +**Manifest for compose/commandBox action:** + +```json +{ + "composeExtensions": [ + { + "botId": "${{BOT_ID}}", + "commands": [ + { + "id": "quickNote", + "type": "action", + "title": "Quick Note", + "description": "Save a quick note", + "context": ["compose", "commandBox"], + "fetchTask": true + } + ] + } + ] +} +``` + +### Shortcut mapping table + +| Slack Pattern | Teams Equivalent | Notes | +|---|---|---| +| `app.shortcut('callback_id')` (global) | `message.ext.open` + `commandId` check | Compose extension action | +| `app.shortcut('callback_id')` (message) | `message.ext.open` + `commandId` check | `context: ['message']` in manifest | +| `shortcut.trigger_id` + `views.open()` | `fetchTask: true` → return task module | No trigger_id needed | +| `shortcut.message` | `activity.value.messagePayload` | Target message content | +| `callback_id` routing | `activity.value.commandId` routing | Different field name | +| `view_submission` handler | `message.ext.submit` handler | Form data in `activity.value.data` | +| `ack()` + background work | Return minimal card + async work | No fire-and-forget | +| `say()` / `respond()` after shortcut | `send()` or return compose card | Can insert into compose box | + +## pitfalls + +- **Missing `composeExtensions` commands in manifest**: Each shortcut must have a corresponding command entry in the manifest with `type: "action"`. Without it, the action never appears in Teams' UI. +- **Forgetting `fetchTask: true`**: Without this flag, Teams won't invoke the `message.ext.open` handler. Instead, it expects parameters defined in the manifest and skips the task module entirely. +- **`context` array determines placement**: Omitting the `context` array or using wrong values means the action appears in unexpected places or not at all. Use `['message']` for message shortcuts, `['compose', 'commandBox']` for global shortcuts. +- **`messagePayload` HTML content**: The target message body in `activity.value.messagePayload.body.content` may be HTML-formatted (not plain text). Parse or strip HTML before using as form default values. +- **No background-only shortcuts**: Slack allows shortcuts that just `ack()` and do work silently. Teams action extensions always present a task module. Wrap background actions in a minimal "Processing..." → "Done" card flow. +- **Submit handler must return within 3 seconds**: Like all invoke activities, the `message.ext.submit` handler must respond quickly. Long-running operations should return immediately and process asynchronously. + +## references + +- https://learn.microsoft.com/en-us/microsoftteams/platform/messaging-extensions/how-to/action-commands/define-action-command +- https://learn.microsoft.com/en-us/microsoftteams/platform/messaging-extensions/how-to/action-commands/create-task-module +- https://learn.microsoft.com/en-us/microsoftteams/platform/messaging-extensions/how-to/action-commands/respond-to-task-module-submit +- https://learn.microsoft.com/en-us/microsoftteams/platform/resources/schema/manifest-schema#composeextensionscommands +- https://github.com/microsoft/teams.ts +- https://api.slack.com/interactivity/shortcuts — Slack shortcuts +- https://api.slack.com/reference/interaction-payloads/shortcuts — Slack shortcut payloads + +## instructions + +Use this expert when adding cross-platform support in either direction for shortcuts and message/compose extensions. It covers: Slack global shortcuts bridged to Teams compose extensions, Slack message shortcuts bridged to Teams action-based extensions with `context: ['message']`, `trigger_id` vs `fetchTask: true`, target message access via `messagePayload`, task module form flows bridged to Slack modals, and reverse mapping from Teams extensions to Slack shortcuts. Pair with `../teams/ui.message-extensions-ts.md` for general message extension patterns, `../teams/ui.dialogs-task-modules-ts.md` for task module details, and `ui-modals-dialogs-ts.md` for modal-to-dialog conversion. + +## research + +Deep Research prompt: + +"Write a micro expert for bridging Slack shortcuts (global shortcuts and message shortcuts) and Microsoft Teams action-based message extensions in either direction. Cover: manifest composeExtensions command config with context arrays, fetchTask: true for task module invocation, trigger_id elimination, message payload access for message shortcuts, view_submission to message.ext.submit, the lack of fire-and-forget shortcuts in Teams, compose box card insertion, and reverse mapping from Teams compose/action extensions back to Slack shortcuts. Include TypeScript code examples and a mapping table." diff --git a/plugins/microsoft-365-agents-toolkit/skills/teams-app-developer/experts/bridge/transport-socketmode-https-ts.md b/plugins/microsoft-365-agents-toolkit/skills/teams-app-developer/experts/bridge/transport-socketmode-https-ts.md new file mode 100644 index 0000000..6033477 --- /dev/null +++ b/plugins/microsoft-365-agents-toolkit/skills/teams-app-developer/experts/bridge/transport-socketmode-https-ts.md @@ -0,0 +1,231 @@ +# transport-socketmode-https-ts + +## purpose + +Bridges Slack transport (Socket Mode, HTTP Events API) and Teams Bot Framework HTTPS transport for cross-platform bots targeting Slack, Teams, or both. + +## rules + +1. **Slack has 3 transport modes; Teams has 1.** Slack supports HTTP webhooks (Events API), Socket Mode (WebSocket for firewalled environments), and RTM (legacy WebSocket). Teams uses exclusively HTTPS via the Azure Bot Framework Service channel. All three Slack transports collapse into one Teams model. [learn.microsoft.com -- Bot Framework](https://learn.microsoft.com/en-us/azure/bot-service/bot-service-overview) +2. **Slack Socket Mode (`@slack/socket-mode`) has NO Teams equivalent.** Socket Mode exists because Slack apps behind firewalls can't receive inbound HTTP. In Teams, the bot MUST expose a public HTTPS endpoint. Use Azure App Service, ngrok (dev), or Azure Dev Tunnels for connectivity. For strict on-premises environments that truly cannot expose any endpoint, Azure Relay provides a hybrid connection where the bot connects outbound to Azure, and Azure proxies inbound Teams traffic through that connection. This adds 10–50ms latency but requires zero inbound firewall rules. [learn.microsoft.com -- Azure Relay](https://learn.microsoft.com/en-us/azure/azure-relay/relay-what-is-it) +3. **Slack's `xapp-` token for Socket Mode → not needed.** Socket Mode uses a special app-level token. Teams uses `CLIENT_ID`/`CLIENT_SECRET`/`TENANT_ID` for all communication. Remove all `SLACK_APP_TOKEN` references. +4. **The WebSocket connection lifecycle disappears.** Slack Socket Mode manages a persistent WebSocket: connect, reconnect on failure, handle `disconnect` events, manage `envelope_id` acknowledgements. In Teams, the Bot Framework sends HTTP POST requests to your endpoint — no connection management needed. +5. **Slack Socket Mode envelope acknowledgement → not needed.** In Socket Mode, each event arrives in an envelope with an `envelope_id` that must be acknowledged within 3 seconds. In Teams, the HTTP response itself IS the acknowledgement — the Bot Framework sends a POST, your server returns 200. +6. **Slack RTM API is fully deprecated — do not port.** If the source project uses RTM (`rtm.start`, `rtm.connect`), it's already legacy. Convert directly to Teams HTTPS handlers without attempting to map RTM patterns. +7. **Teams' deployment model requires a public HTTPS endpoint.** Unlike Socket Mode (outbound-only), Teams bots receive inbound HTTPS from the Bot Framework Service. This means: (a) you need a domain/IP, (b) you need TLS, (c) you need the endpoint registered in the Azure Bot resource. +8. **Slack's retry mechanism (`x-slack-retry-num` header, `x-slack-retry-reason`)** is replaced by Bot Framework delivery guarantees. Teams does not retry failed deliveries in the same way — if your endpoint is down, activities may be lost. Ensure high availability. +9. **Java SDK's `SocketModeClient` classes (`SocketModeApp`, `SocketModeClient`, `JavaxWebSocketClient`, `TyrusWebSocketClient`)** are entirely eliminated. Delete all Socket Mode client code, connection management, reconnection logic, and WebSocket libraries. +10. **Slack's event subscription URL verification challenge (`url_verification` event)** has no Teams equivalent. Teams verifies your endpoint via the Bot Framework registration in Azure Portal, not via an HTTP challenge. Remove all challenge-response code. +11. **Transport is inherently asymmetric.** Slack supports both Socket Mode (outbound WebSocket) and HTTP (inbound webhooks), while Teams requires HTTPS exclusively. For Teams → Slack, adding Socket Mode is optional but useful for firewall-restricted environments. A cross-platform bot typically uses HTTP/HTTPS for both platforms, with Socket Mode as an optional Slack-only enhancement. +12. **Add a health check endpoint for production hosting.** Azure App Service, Container Apps, and Kubernetes all use HTTP health probes to determine if the app is alive. Expose `GET /api/health` returning 200 with a JSON body. Configure the probe path in your hosting platform so failed health checks trigger automatic restarts instead of silent failures. [learn.microsoft.com -- Health checks](https://learn.microsoft.com/en-us/azure/app-service/monitor-instances-health-check) + +## patterns + +### Slack Socket Mode → Teams HTTPS endpoint + +**Slack Socket Mode (before):** + +```typescript +// --- Slack with Socket Mode --- +import { App } from '@slack/bolt'; +import { SocketModeReceiver } from '@slack/bolt'; + +// Socket Mode: outbound WebSocket, no public endpoint needed +const receiver = new SocketModeReceiver({ + appToken: process.env.SLACK_APP_TOKEN!, // xapp-... token + // Manages WebSocket connection lifecycle internally: + // - Connects to wss://wss-primary.slack.com + // - Handles reconnection on disconnect + // - Acknowledges each envelope_id within 3 seconds +}); + +const app = new App({ + token: process.env.SLACK_BOT_TOKEN!, + receiver, // Uses Socket Mode instead of HTTP +}); + +app.message(/hello/i, async ({ say }) => { + await say('Hello via Socket Mode!'); +}); + +await app.start(); +console.log('Connected via WebSocket (no public URL needed)'); +``` + +**Teams (after):** + +```typescript +// --- Teams with HTTPS endpoint --- +import { App } from '@microsoft/teams.apps'; +import { ConsoleLogger } from '@microsoft/teams.common'; +import { DevtoolsPlugin } from '@microsoft/teams.dev'; + +// Teams: inbound HTTPS, public endpoint required +const app = new App({ + clientId: process.env.CLIENT_ID, + clientSecret: process.env.CLIENT_SECRET, + tenantId: process.env.TENANT_ID, + logger: new ConsoleLogger('my-bot', { level: 'info' }), + plugins: [new DevtoolsPlugin()], + // No socket mode, no WebSocket, no app-level token + // Bot Framework sends HTTPS POST to your /api/messages endpoint +}); + +app.message(/hello/i, async ({ send }) => { + await send('Hello via HTTPS!'); +}); + +app.start(3978); +// Requires public HTTPS endpoint: +// - Dev: ngrok http 3978 or Azure Dev Tunnels +// - Prod: Azure App Service with custom domain + TLS +``` + +### Java SDK Socket Mode classes → DELETE + +**Java (before):** + +```java +// --- Java Socket Mode setup (DELETE ALL OF THIS) --- +import com.slack.api.bolt.App; +import com.slack.api.bolt.socket_mode.SocketModeApp; + +// WebSocket client selection +import com.slack.api.socket_mode.SocketModeClient; +import javax.websocket.WebSocketContainer; + +App app = new App(AppConfig.builder() + .singleTeamBotToken(System.getenv("SLACK_BOT_TOKEN")) + .build()); + +app.event(MessageEvent.class, (req, ctx) -> { + ctx.say("Hello!"); + return ctx.ack(); +}); + +// Socket Mode wrapper — manages WebSocket connection lifecycle +SocketModeApp socketModeApp = new SocketModeApp( + System.getenv("SLACK_APP_TOKEN"), // xapp-... token + app // wraps the Bolt app +); +socketModeApp.start(); // connects via WebSocket + +// Internally manages: +// - WebSocket connection to Slack +// - Automatic reconnection +// - Envelope ID acknowledgement +// - Multiple client backends (Tyrus, Java-WebSocket) +``` + +**Teams TypeScript (after):** + +```typescript +// --- Teams: everything above is replaced by this --- +import { App } from '@microsoft/teams.apps'; +import { ConsoleLogger } from '@microsoft/teams.common'; + +const app = new App({ + clientId: process.env.CLIENT_ID, + clientSecret: process.env.CLIENT_SECRET, + tenantId: process.env.TENANT_ID, + logger: new ConsoleLogger('my-bot', { level: 'info' }), +}); + +app.on('message', async ({ send }) => { + await send('Hello!'); +}); + +app.start(3978); +// No SocketModeApp, no WebSocket client, no app-level token +// Delete: SocketModeApp, SocketModeClient, javax.websocket imports, +// Tyrus/Java-WebSocket dependencies, xapp token config +``` + +### Transport comparison table + +| Aspect | Slack HTTP (Events API) | Slack Socket Mode | Teams Bot Framework | +|---|---|---|---| +| Direction | Inbound HTTP POST | Outbound WebSocket | Inbound HTTPS POST | +| Public endpoint | Required | Not required | Required | +| TLS | Required | N/A (outbound) | Required | +| Authentication | Signing secret HMAC | App-level token | Bot Framework JWT (auto) | +| Event delivery | HTTP POST per event | WebSocket frames | HTTPS POST per activity | +| Acknowledgement | Return HTTP 200 in 3s | Send envelope_id ack | Return HTTP 200 | +| Retry on failure | Yes (`x-slack-retry-*`) | Reconnect WebSocket | Limited retries | +| Connection mgmt | Stateless | Client manages WS | Stateless | +| Firewall-friendly | No (needs inbound) | Yes (outbound only) | No (needs inbound) | +| Dev tunneling | ngrok / localtunnel | Not needed | ngrok / Dev Tunnels | + +### Health check endpoint pattern + +```typescript +import express from 'express'; + +const webApp = express(); + +// Health check for Azure App Service / Container Apps probes +webApp.get('/api/health', (req, res) => { + res.status(200).json({ + status: 'healthy', + timestamp: new Date().toISOString(), + uptime: process.uptime(), + }); +}); + +// The Teams app uses the same Express server (or integrate with app.start()) +webApp.listen(process.env.PORT || 3978, () => { + console.log(`Bot running on port ${process.env.PORT || 3978}`); +}); +``` + +### Production deployment stages + +| Stage | Hosting | Endpoint | Notes | +|---|---|---|---| +| **Local dev** | `localhost:3978` + Dev Tunnel | `https://.devtunnels.ms/api/messages` | Free; tunnels expire after idle timeout | +| **Staging** | Azure App Service (B1) | `https://my-bot-staging.azurewebsites.net/api/messages` | Use deployment slots; Always On enabled | +| **Production** | Azure App Service (S1+) or Container Apps | `https://my-bot.azurewebsites.net/api/messages` | Custom domain + managed TLS; health check configured | + +### Environment variable cleanup + +| Slack Variable | Action | Why | +|---|---|---| +| `SLACK_APP_TOKEN` (`xapp-...`) | Delete | Socket Mode only | +| `SLACK_BOT_TOKEN` (`xoxb-...`) | Replace with `CLIENT_ID`+`CLIENT_SECRET` | Different auth model | +| `SLACK_SIGNING_SECRET` | Delete | Bot Framework JWT is auto | +| `SLACK_CLIENT_ID` | Replace with `CLIENT_ID` | Azure Bot app ID | +| `SLACK_CLIENT_SECRET` | Replace with `CLIENT_SECRET` | Azure Bot secret | +| *(add new)* | `TENANT_ID` | Azure AD tenant | + +## pitfalls + +- **Trying to use WebSockets with Teams**: Teams bots use HTTPS, not WebSocket. The Bot Framework Service sends activities as HTTP POST requests. Do not attempt to create a WebSocket server for Teams. +- **Forgetting the public endpoint requirement**: Socket Mode works behind firewalls with no public URL. Teams bots MUST have a public HTTPS endpoint. In development, use `ngrok http 3978` or Azure Dev Tunnels. In production, use Azure App Service. +- **Porting reconnection logic**: Socket Mode clients implement complex reconnection (backoff, failover). Delete all reconnection code — HTTPS is stateless, there's nothing to reconnect. +- **Porting envelope acknowledgement**: Socket Mode requires acknowledging each event's `envelope_id`. Teams has no envelope concept — the HTTP 200 response IS the acknowledgement. Remove all envelope handling. +- **Slack's URL verification challenge**: Slack's Events API sends a `url_verification` challenge to verify your endpoint. Teams doesn't do this — endpoint verification happens during Azure Bot registration. Delete challenge handlers. +- **RTM API patterns**: If the source uses RTM (`rtm.connect`, `rtm.start`), these are completely obsolete even in Slack. Do not attempt to map RTM patterns — convert directly to Teams HTTPS handlers. +- **Missing TLS in production**: Teams requires HTTPS. Azure App Service provides TLS automatically. If self-hosting, you must configure TLS certificates. +- **Assuming event delivery retries**: Slack retries failed HTTP deliveries (with `x-slack-retry-num`). Bot Framework has limited retry guarantees. Design for idempotency but don't depend on retries. +- **Dev tunnels expire**: Azure Dev Tunnels and ngrok free-tier URLs expire after idle timeouts or session restarts. The Bot Framework registration must be updated with the new URL each time. Use a persistent tunnel ID or switch to Azure-hosted staging for stable endpoints. +- **No health check = blind restarts**: Without a health check endpoint, Azure App Service cannot distinguish between a crashed app and a slow response. The platform may restart a healthy but busy instance, or leave a crashed instance running. Always configure `/api/health` and set the health check path in the hosting platform. + +## references + +- https://api.slack.com/apis/connections/socket -- Slack Socket Mode documentation +- https://api.slack.com/apis/connections/events-api -- Slack Events API (HTTP) +- https://api.slack.com/rtm -- Slack RTM API (deprecated) +- https://learn.microsoft.com/en-us/azure/bot-service/bot-service-overview -- Bot Framework architecture +- https://learn.microsoft.com/en-us/azure/bot-service/rest-api/bot-framework-rest-connector-authentication -- Bot Framework authentication +- https://learn.microsoft.com/en-us/microsoftteams/platform/bots/how-to/debug/locally-with-an-ide -- Local dev with tunneling +- https://github.com/microsoft/teams.ts -- Teams SDK v2 + +## instructions + +Use this expert when adding cross-platform support in either direction for Slack transport (Socket Mode, HTTP Events API) or Teams Bot Framework HTTPS transport. The core message: **all three Slack transports collapse into one Teams model (inbound HTTPS)**. Transport is inherently asymmetric -- Slack supports both Socket Mode and HTTP, while Teams requires HTTPS. For Teams → Slack, adding Socket Mode is optional but useful for firewall-restricted environments. Focus on: (1) understanding transport differences between platforms, (2) envelope acknowledgement vs HTTP response patterns, (3) setting up the HTTPS endpoint with proper TLS, (4) configuring Azure Bot registration. Pair with `events-activities-ts.md` for event/activity mapping once the transport layer is resolved, and `../teams/runtime.app-init-ts.md` for Teams app initialization. + +## research + +Deep Research prompt: + +"Write a micro expert for bridging Slack transport (Socket Mode WebSocket, HTTP Events API) and Teams Bot Framework HTTPS transport in either direction for cross-platform bots. Cover: why all three Slack transports collapse into one Teams model, transport asymmetry (Socket Mode is Slack-only), Socket Mode as optional enhancement for firewall-restricted environments, public HTTPS endpoint requirement, Bot Framework JWT authentication, deployment options (Azure App Service, ngrok, Dev Tunnels), environment variable cleanup, and transport comparison table." diff --git a/plugins/microsoft-365-agents-toolkit/skills/teams-app-developer/experts/bridge/ui-app-home-personal-tab-ts.md b/plugins/microsoft-365-agents-toolkit/skills/teams-app-developer/experts/bridge/ui-app-home-personal-tab-ts.md new file mode 100644 index 0000000..ade938a --- /dev/null +++ b/plugins/microsoft-365-agents-toolkit/skills/teams-app-developer/experts/bridge/ui-app-home-personal-tab-ts.md @@ -0,0 +1,318 @@ +# ui-app-home-personal-tab-ts + +## purpose + +Bridges Slack App Home and Teams personal tab / bot welcome card for cross-platform bots targeting Slack, Teams, or both. + +## rules + +1. Slack's App Home is a dedicated per-user tab in the Slack app sidebar, rendered by the bot via `views.publish`. Teams has no direct equivalent of a bot-rendered home tab. The closest alternatives are: (a) a personal bot conversation with a welcome Adaptive Card, (b) a static personal tab (web page in iframe), or (c) a bot-powered tab using `tab.fetch`/`tab.submit` handlers. +2. Slack `app.event(AppHomeOpenedEvent)` fires when a user navigates to the bot's Home tab. In Teams, the equivalent trigger for a personal bot conversation is `app.on('install.add')` (first install) or `app.on('conversationUpdate')` with `membersAdded` (bot added to 1:1 chat). There is no "user opened the chat" event — the bot sends its home card proactively at install time. +3. Slack `views.publish(user_id, view)` publishes a view to a specific user's Home tab. In Teams, send an Adaptive Card to the user's 1:1 conversation using `send()` in the install handler or via proactive messaging. The card serves as the "home" experience. +4. Slack's Home tab Block Kit JSON maps to an Adaptive Card. Convert using the block-kit-to-adaptive-cards mapping table. The card replaces the full home view — use `Container` and `ColumnSet` for layout density. +5. Slack Home tab dynamic updates (re-calling `views.publish` with new content) map to sending a new card or updating the existing card via `updateActivity` in Teams. Store the original activity ID to update it later. +6. Slack's `view.hash` for race condition protection (only update if the hash matches) has no Teams equivalent. Teams card updates via `updateActivity` always overwrite. If concurrent updates are a concern, implement application-level versioning in the card's `Action.Submit.data`. +7. For a richer home experience equivalent to Slack's App Home, consider a **static tab** — a web page declared in the Teams manifest (`staticTabs` array) that loads in an iframe. This supports full HTML/JS and is closer to Slack's App Home flexibility, but requires hosting a web page. +8. Teams SDK v2 supports `tab.fetch` and `tab.submit` handlers for Adaptive Card-based tabs (no iframe needed). The bot returns an Adaptive Card in response to `tab.fetch`, and handles form submissions via `tab.submit`. This is the closest behavioral match to Slack's `views.publish` pattern. +9. When migrating App Home with action buttons, remember that Slack Home tab actions fire `blockAction` events. In Teams, Adaptive Card buttons in 1:1 chat fire `adaptiveCards.actionSubmit` handlers. The routing mechanism changes but the concept is the same. +10. Slack App Home can show different content per user based on `event.user`. In Teams 1:1 chat, the bot always talks to one user, so personalization is inherent. For tab-based approaches, use `tab.fetch` which receives user context in the activity. +11. **Reverse direction (Teams → Slack):** For Teams → Slack, map `tab.fetch` to `app_home_opened` event with `views.publish` for dynamic content. The Adaptive Card tab content maps to Block Kit views. `tab.submit` actions map to `view_submission` or `block_actions` events. The `install.add` welcome card maps to a `views.publish` call triggered by `app_home_opened`. + +## patterns + +### Option A: Welcome card on install (simplest) + +**Slack (before):** + +```kotlin +app.event(AppHomeOpenedEvent::class.java) { e, ctx -> + val res = ctx.client().viewsPublish { + it.userId(e.event.user) + .viewAsString(homeViewJson) + .hash(e.event.view?.hash) + } + ctx.ack() +} +``` + +**Teams (after):** + +```typescript +import { App } from '@microsoft/teams.apps'; +import { ConsoleLogger } from '@microsoft/teams.common'; + +const app = new App({ + logger: new ConsoleLogger('home-bot'), +}); + +// Send a "home" card when the bot is installed (replaces AppHomeOpenedEvent) +app.on('install.add', async ({ send }) => { + await send({ + type: 'message', + attachments: [{ + contentType: 'application/vnd.microsoft.card.adaptive', + content: { + type: 'AdaptiveCard', + version: '1.5', + body: [ + { + type: 'TextBlock', + text: 'Welcome to the App!', + size: 'Large', + weight: 'Bolder', + }, + { + type: 'TextBlock', + text: `Last updated: ${new Date().toISOString()}`, + isSubtle: true, + wrap: true, + }, + ], + actions: [ + { + type: 'Action.Submit', + title: 'Action A', + data: { verb: 'actionA' }, + }, + { + type: 'Action.Submit', + title: 'Action B', + data: { verb: 'actionB' }, + }, + ], + }, + }], + }); +}); + +// Handle button clicks on the home card +app.on('adaptiveCards.actionSubmit' as any, async ({ activity, send }) => { + const verb = activity.value?.verb; + if (verb === 'actionA') { + await send('You clicked Action A!'); + } else if (verb === 'actionB') { + await send('You clicked Action B!'); + } +}); + +app.start(3978); +``` + +### Option B: Adaptive Card tab (closest to App Home) + +```typescript +import { App } from '@microsoft/teams.apps'; +import { ConsoleLogger } from '@microsoft/teams.common'; + +const app = new App({ + logger: new ConsoleLogger('tab-bot'), +}); + +// tab.fetch replaces AppHomeOpenedEvent — fires when user opens the tab +app.on('tab.fetch' as any, async ({ activity }) => { + const userId = activity.from?.id; + return { + status: 200, + body: { + tab: { + type: 'continue', + value: { + cards: [{ + card: { + contentType: 'application/vnd.microsoft.card.adaptive', + content: { + type: 'AdaptiveCard', + version: '1.5', + body: [ + { + type: 'TextBlock', + text: 'Home', + size: 'Large', + weight: 'Bolder', + }, + { + type: 'TextBlock', + text: `Hello, user ${userId}! Updated: ${new Date().toISOString()}`, + wrap: true, + }, + { + type: 'ActionSet', + actions: [ + { + type: 'Action.Submit', + title: 'Refresh', + data: { verb: 'refresh' }, + }, + ], + }, + ], + }, + }, + }], + }, + }, + }, + }; +}); + +// tab.submit handles actions within the tab +app.on('tab.submit' as any, async ({ activity }) => { + const verb = activity.value?.data?.verb; + if (verb === 'refresh') { + // Return updated tab content + return { + status: 200, + body: { + tab: { + type: 'continue', + value: { + cards: [{ + card: { + contentType: 'application/vnd.microsoft.card.adaptive', + content: { + type: 'AdaptiveCard', + version: '1.5', + body: [{ + type: 'TextBlock', + text: `Refreshed at ${new Date().toISOString()}`, + }], + }, + }, + }], + }, + }, + }, + }; + } + return { status: 200, body: {} }; +}); + +app.start(3978); +``` + +### Option C: Static tab with hosted web page (most flexible) + +**Manifest `staticTabs` entry:** + +```json +{ + "staticTabs": [ + { + "entityId": "homeTab", + "name": "Home", + "contentUrl": "https://your-app.azurewebsites.net/tab/home", + "scopes": ["personal"] + } + ], + "validDomains": [ + "your-app.azurewebsites.net" + ] +} +``` + +**Express route serving the tab page:** + +```typescript +import express from 'express'; +import path from 'path'; + +const webApp = express(); + +// Serve static assets +webApp.use('/tab/assets', express.static(path.join(__dirname, 'public'))); + +// Tab page route — returns HTML that initializes the Teams JS SDK +webApp.get('/tab/home', (req, res) => { + res.send(` + + + + Home + + + + +
Loading...
+ + +`); +}); + +// API endpoint for tab actions +webApp.post('/tab/api/action', express.json(), (req, res) => { + const { verb } = req.body; + res.json({ status: 'ok', verb, processedAt: new Date().toISOString() }); +}); + +webApp.listen(3000, () => console.log('Tab server on :3000')); +``` + +### Bridging decision table + +| Slack App Home Feature | Option A: 1:1 Welcome Card | Option B: Adaptive Card Tab | Option C: Static Tab (iframe) | +|---|---|---|---| +| Trigger on open | `install.add` (once) | `tab.fetch` (every open) | Page load | +| Dynamic content | Proactive message update | Return new card on each fetch | Full web app | +| User actions | `actionSubmit` handlers | `tab.submit` handlers | Web forms/JS | +| Complexity | Low | Medium | High | +| Manifest changes | None | `staticTabs` with `contentBotId` | `staticTabs` with `contentUrl` | +| Best for | Simple welcome/info | Dashboard-like home tabs | Rich interactive UIs | + +## pitfalls + +- **No "opened" event in 1:1 chat**: Slack fires `AppHomeOpenedEvent` every time the user navigates to the Home tab. Teams has no equivalent for 1:1 bot chat. The bot is notified when installed, not when the user opens the chat. Use `tab.fetch` (Option B) if you need an on-open trigger. +- **views.publish is proactive**: Slack's `views.publish` can be called anytime to update the Home tab for any user. In Teams, updating a 1:1 message requires a stored conversation reference and the original activity ID. Set up proactive messaging infrastructure if you need background updates. +- **Race condition protection gone**: Slack's `view.hash` prevents concurrent updates from clobbering each other. Teams has no equivalent. If multiple processes might update the same card, implement optimistic locking in your application layer. +- **Block Kit → Adaptive Card**: The home view's Block Kit JSON must be converted to an Adaptive Card. The Home tab often uses `actions` blocks with buttons — these become `Action.Submit` buttons in the Adaptive Card. See `ui-block-kit-adaptive-cards-ts.md` for the full mapping. +- **Manifest required for tabs**: Options B and C require a `staticTabs` entry in the Teams manifest. Option A (1:1 chat) does not require manifest changes beyond the base bot registration. +- **Tab card size limits**: Adaptive Card tabs are subject to the same 28 KB card size limit. If the Slack Home tab rendered long lists, paginate or load data on demand. +- **Static tab requires a hosted web page**: Option C (static tab) requires deploying and hosting a web page accessible via HTTPS. This is a separate hosting concern from the bot itself. Use the same Azure App Service or add a route to your existing Express server. +- **`validDomains` must include the tab host**: If the `contentUrl` domain is not listed in the manifest's `validDomains` array, Teams will refuse to load the tab with a blank iframe. This is the most common static tab deployment failure. +- **Teams JS SDK initialization is mandatory**: Every tab page must call `microsoftTeams.app.initialize()` before accessing any Teams context. Without it, the tab loads but `getContext()` returns nothing and deep links fail. The SDK script must be loaded from the official CDN or npm package. + +## references + +- https://api.slack.com/surfaces/app-home — Slack App Home documentation +- https://api.slack.com/events/app_home_opened — AppHomeOpenedEvent reference +- https://api.slack.com/methods/views.publish — views.publish API +- https://learn.microsoft.com/en-us/microsoftteams/platform/tabs/what-are-tabs — Teams tabs overview +- https://learn.microsoft.com/en-us/microsoftteams/platform/tabs/how-to/create-personal-tab — Personal tabs +- https://learn.microsoft.com/en-us/microsoftteams/platform/bots/how-to/conversations/send-proactive-messages — Proactive messaging +- https://github.com/microsoft/teams.ts — Teams SDK v2 + +## instructions + +Use this expert when adding cross-platform support in either direction for Slack App Home or Teams personal tab / bot welcome card. It covers three bridging paths: (A) a welcome Adaptive Card in 1:1 bot chat (simplest), (B) an Adaptive Card-based tab using `tab.fetch`/`tab.submit` (closest to App Home behavior), and (C) a static web tab in an iframe (most flexible). For Teams → Slack, map `tab.fetch` to `app_home_opened` event with `views.publish` for dynamic content. The decision table helps choose the right approach based on requirements. Pair with `ui-block-kit-adaptive-cards-ts.md` for converting between Block Kit and Adaptive Cards, `../teams/ui.adaptive-cards-ts.md` for card construction, and `../teams/runtime.proactive-messaging-ts.md` for background card updates. + +## research + +Deep Research prompt: + +"Write a micro expert on bridging Slack App Home (AppHomeOpenedEvent, views.publish, dynamic home tab with Block Kit) and Microsoft Teams personal tab / bot welcome card in either direction. Cover three approaches: 1:1 bot welcome card, Adaptive Card-based tabs (tab.fetch/tab.submit), and static tabs (iframe). Include reverse-direction notes for Teams → Slack mapping, a decision matrix, side-by-side code examples, and pitfalls around proactive messaging, race conditions, and manifest configuration." diff --git a/plugins/microsoft-365-agents-toolkit/skills/teams-app-developer/experts/bridge/ui-block-kit-adaptive-cards-ts.md b/plugins/microsoft-365-agents-toolkit/skills/teams-app-developer/experts/bridge/ui-block-kit-adaptive-cards-ts.md new file mode 100644 index 0000000..c528940 --- /dev/null +++ b/plugins/microsoft-365-agents-toolkit/skills/teams-app-developer/experts/bridge/ui-block-kit-adaptive-cards-ts.md @@ -0,0 +1,453 @@ +# ui-block-kit-adaptive-cards-ts + +## purpose + +Bridges Slack Block Kit and Teams Adaptive Card UI structures for cross-platform bots targeting Slack, Teams, or both. + +## rules + +1. Target Adaptive Cards schema version `1.5` for Teams desktop/mobile compatibility (learn.microsoft.com/en-us/microsoftteams/platform/task-modules-and-cards/cards/cards-format). +2. Every Slack `action_id` must become a key inside the Adaptive Card `Action.Submit.data` object so the bot can route by `data.action` (adaptivecards.io/explorer/Action.Submit.html). +3. Slack `block_id` has no direct equivalent -- encode it in `Action.Submit.data.blockId` if you need round-trip tracing. +4. Slack mrkdwn uses `*bold*` and `_italic_`; Adaptive Cards use standard Markdown (`**bold**`, `_italic_`) inside `TextBlock.text` with `"style": "default"` (adaptivecards.io/explorer/TextBlock.html). +5. Slack `image_url` fields map to `Image.url`; always set `Image.altText` (required for accessibility in Teams). +6. Slack modals (`views.open` / `views.push`) map to Teams task modules invoked via `task/fetch` and rendered with an Adaptive Card; submission maps to `task/submit` (learn.microsoft.com/en-us/microsoftteams/platform/task-modules-and-cards/task-modules/task-modules-bots). +7. Slack `view_submission` payload fields map: `view.state.values[block_id][action_id].value` becomes the flat `data` object returned by `task/submit`, keyed by each input's `id`. +8. Slack `static_select` maps to `Input.ChoiceSet` with `"style": "compact"`. Slack `multi_static_select` maps to `Input.ChoiceSet` with `"isMultiSelect": true` (adaptivecards.io/explorer/Input.ChoiceSet.html). +9. Slack `overflow` menu has no Adaptive Card equivalent -- redesign as `ActionSet` with multiple `Action.Submit` buttons or a single `Input.ChoiceSet` dropdown. +10. Teams Adaptive Cards support `ColumnSet`/`Column` and `FactSet` which have no Block Kit equivalent -- use them to improve layout density during migration. + +## strategy + +The core principle: **map the blocks mechanically, then redesign the layout and interaction model to be native Teams rather than ported Slack.** A 1:1 block-to-element swap produces a functional card that looks like Slack awkwardly wearing a Teams suit. Follow these four phases in order. + +### Phase 1: Map for correctness + +Get every block producing the correct output using the mapping table below. This is mechanical work: +- `header` → `TextBlock` Large/Bolder +- `section` fields → `FactSet` +- `button` → `Action.Submit` with `data.verb` +- Convert `*bold*` mrkdwn to `**bold**` standard Markdown +- Replace `:emoji_shortcodes:` with Unicode equivalents (Teams does not render Slack shortcodes) +- Replace `<@U12345>` mentions with display names (Slack mention syntax does not work in Adaptive Cards) +- Swap button styles: `"primary"` → `"positive"`, `"danger"` → `"destructive"` +- Add explicit submit buttons wherever Slack had instant-fire selects + +### Phase 2: Upgrade the layout + +Once correct, leverage Adaptive Card strengths that have no Block Kit equivalent: +- Replace flat block lists with `ColumnSet`/`Container` for denser, structured layouts +- Use semantic container styles (`"attention"` = red, `"good"` = green, `"warning"` = yellow) instead of faking status with emoji +- Add client-side validation (`isRequired`, `errorMessage`, `regex`, `min`/`max`) instead of relying entirely on server-side checks +- Use `Input.ChoiceSet` with `"style": "filtered"` for typeahead search to replace `external_select` server-side handlers +- Use `FactSet` for clean key/value pairs instead of manual mrkdwn formatting in `section.fields` + +### Phase 3: Rethink the interaction model + +This is the biggest behavioral shift. Slack's model is **event-per-interaction** -- every select and button fires immediately. Teams' model is **form-then-submit**. +- Group related inputs together and submit them as a batch with a single `Action.Submit` +- Accept fewer round trips -- the UX feels different, so lean into it rather than fighting it +- Use `Action.Execute` with the card `refresh` property if you genuinely need per-interaction updates or per-user card views +- Slack ephemeral messages for per-user content → Universal Actions (`Action.Execute`) for per-user card states from the same message + +### Phase 4: Handle what doesn't convert + +Have an explicit plan for each gap: +- `overflow` menu → redesign as `Input.ChoiceSet` dropdown or an `ActionSet` with multiple buttons +- Stacked modals (`views.push`) → flatten into multi-step cards or sequential task modules (Teams task modules do not stack) +- `dispatch_action` live updates → accept the batch-submit model, or use `Action.Execute` refresh for critical cases +- `private_metadata` → embed hidden state in `Action.Submit.data` fields or use bot conversation state +- `view_submission` with field-level errors keeping the modal open → no equivalent in Teams; validate client-side with `isRequired`/`regex`, or close the task module and send an error message +- Action count overflow (Slack allows 25 per block, Teams allows 6 per `ActionSet`) → paginate into multiple cards or consolidate into dropdowns + +## patterns + +### mapping-table + +| Slack Block Kit | Adaptive Card Element | Notes | +|---------------------------|-------------------------------|----------------------------------------------------| +| `section` (text) | `TextBlock` | Set `wrap: true`; convert mrkdwn to standard MD | +| `section` (text+accessory)| `ColumnSet` with 2 `Column`s | Col 1 = TextBlock, Col 2 = accessory element | +| `section` (fields) | `FactSet` | Each field becomes a `Fact { title, value }` | +| `actions` | `ActionSet` | Contains `Action.Submit` / `Action.OpenUrl` | +| `divider` | `TextBlock` with `separator` | `{ "type": "TextBlock", "text": " ", "separator": true }` | +| `header` | `TextBlock` size `Large` | `{ "type": "TextBlock", "size": "Large", "weight": "Bolder" }` | +| `image` | `Image` | Set `url`, `altText`, optional `size` | +| `context` | `TextBlock` size `Small` | `{ "type": "TextBlock", "size": "Small", "isSubtle": true }` | +| `input` (plain_text) | `Input.Text` | `id` = action_id, `label` maps to `Input.Text.label` | +| `input` (static_select) | `Input.ChoiceSet` | `style: "compact"` for dropdown | +| `input` (multi_select) | `Input.ChoiceSet` multiSelect | `"isMultiSelect": true` | +| `input` (datepicker) | `Input.Date` | Format: `YYYY-MM-DD` | +| `input` (timepicker) | `Input.Time` | Format: `HH:mm` | +| `input` (checkboxes) | `Input.ChoiceSet` expanded | `"style": "expanded", "isMultiSelect": true` | +| `input` (radio_buttons) | `Input.ChoiceSet` expanded | `"style": "expanded", "isMultiSelect": false` | +| `rich_text` | `TextBlock` + `RichTextBlock` | RichTextBlock available in schema 1.5+ | + +### actions-mapping + +| Slack Element | Adaptive Card Action | Key Differences | +|----------------------|----------------------------|----------------------------------------------------| +| `button` | `Action.Submit` | `value` moves into `data`; `style: "danger"` maps to `style: "destructive"` | +| `button` (url) | `Action.OpenUrl` | `url` field is identical | +| `overflow` | *No equivalent* | Redesign as `ActionSet` or `Input.ChoiceSet` | +| `static_select` | `Input.ChoiceSet` + Submit | Slack fires on select; Teams needs explicit submit | +| `external_select` | `Input.ChoiceSet` + `Action.Submit` with `data.query` | Implement typeahead via `Input.ChoiceSet` with `"style": "filtered"` (schema 1.5) | +| `multi_static_select`| `Input.ChoiceSet` multi | Teams returns comma-separated string of values | + +### reverse-direction (Teams → Slack) + +For Teams → Slack, reverse the mapping table. Adaptive Card elements map back to Block Kit blocks: +- `TextBlock` Large/Bolder → `header` +- `FactSet` → `section` with `fields` +- `Action.Submit` with `data.verb` → `button` with `value` +- Convert `**bold**` standard Markdown to `*bold*` mrkdwn +- Replace Unicode emoji with `:emoji_shortcodes:` where Slack supports them +- Swap button styles: `"positive"` → `"primary"`, `"destructive"` → `"danger"` +- `ColumnSet`/`Container` layouts → flatten to linear `section` blocks (Block Kit has no grid) +- `Input.ChoiceSet` with `style: "filtered"` → `external_select` with server-side options handler +- `Input.ChoiceSet` + `Action.Submit` → `static_select` in `actions` block (fires immediately on select) +- `Action.Execute` per-user refresh → ephemeral messages for per-user content +- Client-side validation (`isRequired`, `regex`) → server-side validation in `view_submission` handler +- Semantic container styles (`"attention"`, `"good"`) → emoji-based status indicators or colored attachment sidebars + +Key behavioral shift (Teams → Slack): The Adaptive Card **form-then-submit** model must be decomposed into Slack's **event-per-interaction** model. Each input that previously submitted as part of a batch may need its own `block_actions` handler if the Slack UX expects instant-fire behavior. + +### worked-example-1: button workflow + +Slack Block Kit message with approve/reject buttons converted to Adaptive Card. + +```typescript +// --- Slack Block Kit (original) --- +import type { KnownBlock } from "@slack/types"; + +const slackBlocks: KnownBlock[] = [ + { + type: "section", + block_id: "request_info", + text: { type: "mrkdwn", text: "*Expense Report #1042*\nAmount: $350.00" }, + }, + { + type: "actions", + block_id: "approval_actions", + elements: [ + { + type: "button", + action_id: "approve_expense", + text: { type: "plain_text", text: "Approve" }, + style: "primary", + value: "1042", + }, + { + type: "button", + action_id: "reject_expense", + text: { type: "plain_text", text: "Reject" }, + style: "danger", + value: "1042", + }, + ], + }, +]; + +// --- Adaptive Card (converted) --- +interface AdaptiveCard { + type: "AdaptiveCard"; + $schema: string; + version: string; + body: Record[]; + actions?: Record[]; +} + +const adaptiveCard: AdaptiveCard = { + type: "AdaptiveCard", + $schema: "http://adaptivecards.io/schemas/adaptive-card.json", + version: "1.5", + body: [ + { + type: "TextBlock", + text: "**Expense Report #1042**\nAmount: $350.00", + wrap: true, + }, + ], + actions: [ + { + type: "Action.Submit", + title: "Approve", + style: "positive", + data: { + action: "approve_expense", + blockId: "approval_actions", + value: "1042", + }, + }, + { + type: "Action.Submit", + title: "Reject", + style: "destructive", + data: { + action: "reject_expense", + blockId: "approval_actions", + value: "1042", + }, + }, + ], +}; +``` + +Handler comparison: + +```typescript +// --- Slack handler (Bolt) --- +// app.action("approve_expense", async ({ action, ack, respond }) => { +// await ack(); +// const expenseId = action.value; // "1042" +// await respond({ text: `Expense ${expenseId} approved.` }); +// }); + +// --- Teams handler (Teams AI SDK) --- +import { App, TurnState } from "@microsoft/teams-ai"; +import { CardFactory } from "botbuilder"; + +export function registerExpenseHandlers(app: App): void { + app.adaptiveCards.actionSubmit("approve_expense", async (ctx, _state, data) => { + const expenseId = (data as Record).value; // "1042" + const reply = CardFactory.adaptiveCard({ + type: "AdaptiveCard", + version: "1.5", + body: [{ type: "TextBlock", text: `Expense ${expenseId} approved.` }], + }); + await ctx.updateActivity({ + type: "message", + id: ctx.activity.replyToId, + attachments: [reply], + }); + return undefined; + }); +} +``` + +### worked-example-2: modal form + +Slack modal with text input and select converted to Teams task module with Adaptive Card form. + +```typescript +// --- Slack modal (original, opened via views.open) --- +import type { View } from "@slack/types"; + +const slackModal: View = { + type: "modal", + callback_id: "create_ticket", + title: { type: "plain_text", text: "Create Ticket" }, + submit: { type: "plain_text", text: "Submit" }, + blocks: [ + { + type: "input", + block_id: "title_block", + label: { type: "plain_text", text: "Title" }, + element: { + type: "plain_text_input", + action_id: "ticket_title", + placeholder: { type: "plain_text", text: "Enter title..." }, + }, + }, + { + type: "input", + block_id: "priority_block", + label: { type: "plain_text", text: "Priority" }, + element: { + type: "static_select", + action_id: "ticket_priority", + options: [ + { text: { type: "plain_text", text: "High" }, value: "high" }, + { text: { type: "plain_text", text: "Medium" }, value: "medium" }, + { text: { type: "plain_text", text: "Low" }, value: "low" }, + ], + }, + }, + ], +}; + +// --- Adaptive Card for Teams task module (converted) --- +const taskModuleCard = { + type: "AdaptiveCard" as const, + $schema: "http://adaptivecards.io/schemas/adaptive-card.json", + version: "1.5", + body: [ + { + type: "TextBlock", + text: "Create Ticket", + size: "Large", + weight: "Bolder", + }, + { + type: "Input.Text", + id: "ticket_title", + label: "Title", + placeholder: "Enter title...", + isRequired: true, + }, + { + type: "Input.ChoiceSet", + id: "ticket_priority", + label: "Priority", + style: "compact", + isRequired: true, + choices: [ + { title: "High", value: "high" }, + { title: "Medium", value: "medium" }, + { title: "Low", value: "low" }, + ], + }, + ], + actions: [ + { + type: "Action.Submit", + title: "Submit", + data: { action: "create_ticket" }, + }, + ], +}; +``` + +Task module invocation and submission handler: + +```typescript +import { + TeamsActivityHandler, + TurnContext, + TaskModuleResponse, + CardFactory, +} from "botbuilder"; + +class TicketBot extends TeamsActivityHandler { + // Replaces Slack's views.open -- triggered by messaging extension or Action.Submit + async handleTeamsTaskModuleFetch( + context: TurnContext + ): Promise { + return { + task: { + type: "continue", + value: { + title: "Create Ticket", + width: "medium", + height: "medium", + card: CardFactory.adaptiveCard(taskModuleCard), + }, + }, + }; + } + + // Replaces Slack's view_submission handler + async handleTeamsTaskModuleSubmit( + context: TurnContext + ): Promise { + const formData = context.activity.value?.data as { + action: string; + ticket_title: string; + ticket_priority: string; + }; + + // Slack: view.state.values.title_block.ticket_title.value + const title = formData.ticket_title; + // Slack: view.state.values.priority_block.ticket_priority.selected_option.value + const priority = formData.ticket_priority; + + await context.sendActivity(`Ticket created: "${title}" [${priority}]`); + + // Return void to close the task module (like no response_action in Slack) + return undefined; + } +} +``` + +### Confirmation dialog pattern (Y14) + +Use `Action.ShowCard` for inline confirmation — the Teams equivalent of Slack's native `confirm` object on buttons. + +```typescript +// Slack: button with confirm dialog +const slackButton = { + type: "button", + text: { type: "plain_text", text: "Delete" }, + style: "danger", + action_id: "delete_item", + value: "42", + confirm: { + title: { type: "plain_text", text: "Are you sure?" }, + text: { type: "mrkdwn", text: "This action cannot be undone." }, + confirm: { type: "plain_text", text: "Yes, delete" }, + deny: { type: "plain_text", text: "Cancel" }, + }, +}; + +// Teams: Action.ShowCard inline confirmation +const teamsConfirmAction = { + type: "Action.ShowCard", + title: "Delete", + card: { + type: "AdaptiveCard", + body: [ + { + type: "TextBlock", + text: "Are you sure? This action cannot be undone.", + weight: "Bolder", + color: "Attention", + }, + ], + actions: [ + { + type: "Action.Submit", + title: "Yes, delete", + style: "destructive", + data: { action: "confirm_delete", itemId: "42" }, + }, + { + type: "Action.Submit", + title: "Cancel", + data: { action: "cancel_delete" }, + }, + ], + }, +}; +``` + +**Why `Action.ShowCard`:** Expands inline without leaving the current context — closest to Slack's native `confirm` popup. No task module overhead. + +**Don't:** Open a full task module dialog for a simple yes/no confirmation. It's too heavy for the interaction. + +**Reverse (Teams → Slack):** Add a `confirm` object directly to the button element. Platform-rendered popup with zero effort. + +## pitfalls + +- **mrkdwn vs Markdown**: Slack uses `*bold*` and `~strike~`; Adaptive Cards expect `**bold**` and `~~strike~~`. Failing to convert produces literal asterisks in Teams. +- **Instant-fire selects**: Slack `static_select` inside an `actions` block fires `block_actions` immediately on selection. Adaptive Card `Input.ChoiceSet` does nothing until an `Action.Submit` is clicked -- you must add an explicit submit button. +- **Button style names differ**: Slack `"primary"` = green, `"danger"` = red. Adaptive Cards use `"positive"` and `"destructive"`. Using Slack names silently falls back to default styling. +- **Action count limit**: Teams Adaptive Cards support a maximum of 6 actions per `ActionSet`. Slack allows up to 25 elements in an `actions` block. Redesign dense action rows into paginated cards or dropdowns. +- **`overflow` menu**: No Adaptive Card equivalent exists. Replace with an `Input.ChoiceSet` dropdown or multiple `Action.Submit` buttons. +- **`multi_static_select` return format**: Slack returns `selected_options` as an array of objects. `Input.ChoiceSet` with `isMultiSelect` returns a single comma-separated string (e.g., `"a,b,c"`). Split server-side. +- **No `dispatch_action` equivalent**: Slack inputs can set `dispatch_action: true` to fire events on every keystroke. Adaptive Cards only submit on explicit `Action.Submit`. +- **Image sizing**: Slack `image` uses `alt_text` (underscore); Adaptive Card `Image` uses `altText` (camelCase). Slack fills width by default; set Adaptive Card `"size": "stretch"` to match. +- **`private_metadata`**: Slack modals carry `private_metadata` for state. In Teams task modules, embed hidden state inside `Action.Submit.data` fields or use bot conversation state. +- **Schema version**: Using features above 1.5 (e.g., `Action.Execute` for Universal Actions) requires verifying Teams client support. Stick to 1.5 for broadest compatibility. +- **Card replacement**: Slack `respond({ replace_original: true })` replaces the message. In Teams, use `context.updateActivity()` with the original activity ID, or return an `adaptiveCard/action` invoke response. + +## references + +- https://api.slack.com/reference/block-kit/blocks -- Slack Block Kit block type reference +- https://api.slack.com/reference/block-kit/block-elements -- Slack interactive element reference +- https://api.slack.com/surfaces/modals -- Slack modal (views.open) documentation +- https://adaptivecards.io/explorer/ -- Adaptive Cards schema explorer (all element types) +- https://adaptivecards.io/explorer/Action.Submit.html -- Action.Submit schema and data field +- https://adaptivecards.io/explorer/Input.ChoiceSet.html -- Input.ChoiceSet (select/multi-select) +- https://learn.microsoft.com/en-us/microsoftteams/platform/task-modules-and-cards/cards/cards-reference -- Teams Adaptive Card support +- https://learn.microsoft.com/en-us/microsoftteams/platform/task-modules-and-cards/task-modules/task-modules-bots -- Task modules from bots +- https://learn.microsoft.com/en-us/adaptive-cards/authoring-cards/universal-action-model -- Universal Actions + +## instructions + +This expert covers bridging Slack Block Kit and Teams Adaptive Card UI structures in TypeScript. Use it when adding cross-platform support in either direction: (1) bridging a Slack Bolt app to also target Teams, (2) bridging a Teams bot to also target Slack, (3) converting Block Kit JSON payloads to Adaptive Card JSON or vice versa, (4) redesigning modal workflows into task modules or task modules into modals, or (5) mapping interactive action handlers between platforms. Start with the strategy section to understand the four-phase approach (map for correctness → upgrade layout → rethink interactions → handle gaps), consult the mapping table and reverse-direction section for specific element types, and adapt the worked examples to your use case. Pair with `../slack/ui.block-kit-ts.md` for Slack Block Kit patterns, and `../teams/ui.adaptive-cards-ts.md` for Teams Adaptive Card patterns and constraints. + +## research + +Deep Research prompt: + +"Write a micro expert for bridging Slack Block Kit and Teams Adaptive Cards bidirectionally. Include: mapping table (Block Kit blocks <-> card elements) in both directions, interactive actions mapping (action_id <-> data.action), selects/inputs mapping, modal/task-module workflow redesign in both directions, unsupported features and redesign recommendations for each platform, and 2 worked examples (a button workflow and a modal form)." diff --git a/plugins/microsoft-365-agents-toolkit/skills/teams-app-developer/experts/bridge/ui-legacy-attachments-cards-ts.md b/plugins/microsoft-365-agents-toolkit/skills/teams-app-developer/experts/bridge/ui-legacy-attachments-cards-ts.md new file mode 100644 index 0000000..0c415eb --- /dev/null +++ b/plugins/microsoft-365-agents-toolkit/skills/teams-app-developer/experts/bridge/ui-legacy-attachments-cards-ts.md @@ -0,0 +1,206 @@ +# ui-legacy-attachments-cards-ts + +## purpose + +Bridges pre-Block Kit Slack legacy attachments and Teams Adaptive Cards for cross-platform bots targeting Slack, Teams, or both. + +## rules + +1. Slack legacy attachments (`message.attachments[]`) predate Block Kit and use a flat JSON structure with `text`, `fallback`, `color`, `callback_id`, and `actions[]`. These map to a single Adaptive Card with `TextBlock` body elements and `Action.Submit` actions. +2. Slack `app.attachmentAction(callback_id)` handles button clicks on legacy attachments. In Teams, this maps to `app.on('adaptiveCards.actionSubmit')` or `app.adaptiveCards.actionSubmit(verb, handler)` where `verb` is embedded in `Action.Submit.data`. +3. Slack legacy attachment `color` (hex string like `"#3AA3E3"` or named like `"good"`, `"warning"`, `"danger"`) maps to Adaptive Card `Container` with `style` property: `"good"` → `"good"`, `"warning"` → `"warning"`, `"danger"` → `"attention"`. For custom hex colors, wrap the card content in a `Container` with `"style": "emphasis"` (no arbitrary hex colors in Adaptive Cards). +4. Slack legacy attachment `fallback` (plain-text fallback for notifications) maps to the `fallback` property on the Adaptive Card's `content` object (e.g., `{ ..., "fallback": "Fallback text for notifications" }`). Always provide this for accessibility. +5. Slack legacy attachment `actions[]` with `type: "button"` map to Adaptive Card `Action.Submit` buttons. The button `name` and `value` become keys in `Action.Submit.data`. The `callback_id` becomes the `verb` routing key. +6. Slack legacy `confirm` objects (confirmation dialogs on buttons) have no direct Adaptive Card equivalent. Redesign as: (a) an `Action.ShowCard` that reveals a confirmation sub-card with Confirm/Cancel buttons, or (b) a two-step flow where the first click sends a confirmation card and the second click executes the action. +7. Slack `attachment_type: "default"` has no Adaptive Card equivalent — it was a Slack internal marker. Remove it during migration. +8. Slack legacy attachment `actions[]` with `type: "select"` (dropdown menus) map to Adaptive Card `Input.ChoiceSet` with `style: "compact"`. Remember that Adaptive Card selects require an explicit `Action.Submit` button — they do not fire on selection like Slack. +9. Slack `respond({ replace_original: true })` (replacing the original message after an attachment action) maps to Teams `updateActivity()` with the original activity ID and a new Adaptive Card attachment. +10. Messages mixing legacy attachments AND Block Kit blocks should be bridged to a single Adaptive Card. The attachment text becomes header/body `TextBlock` elements and the Block Kit portion follows the standard block-kit-to-adaptive-cards mapping. +11. **Reverse direction (Teams → Slack):** While not recommended (Block Kit is preferred), Adaptive Cards can be mapped to legacy attachment format if targeting very old Slack integrations. Map `TextBlock` to `attachments[].text`, `Container` style to `color`, and `Action.Submit` to `actions[].type: "button"`. Prefer converting to Block Kit instead of legacy attachments for new Slack integrations. + +## patterns + +### Legacy attachment with buttons → Adaptive Card + +**Slack (before):** + +```kotlin +// --- Slack legacy attachment JSON --- +val message = """ +{ + "text": "Would you like to play a game?", + "attachments": [ + { + "text": "Choose a game to play", + "fallback": "You are unable to choose a game", + "callback_id": "wopr_game", + "color": "#3AA3E3", + "attachment_type": "default", + "actions": [ + { "name": "game", "text": "Chess", "type": "button", "value": "chess" }, + { "name": "game", "text": "Falken's Maze", "type": "button", "value": "maze" }, + { + "name": "game", + "text": "Thermonuclear War", + "style": "danger", + "type": "button", + "value": "war", + "confirm": { + "title": "Are you sure?", + "text": "Wouldn't you prefer a good game of chess?", + "ok_text": "Yes", + "dismiss_text": "No" + } + } + ] + } + ] +} +""" + +app.attachmentAction("wopr_game") { req, ctx -> + ctx.respond(secondMessage) + ctx.ack() +} +``` + +**Teams (after):** + +```typescript +import { App } from '@microsoft/teams.apps'; +import { ConsoleLogger } from '@microsoft/teams.common'; + +const app = new App({ + logger: new ConsoleLogger('game-bot'), +}); + +// The game selection card (replaces legacy attachment) +const gameCard = { + type: 'AdaptiveCard' as const, + version: '1.5', + fallback: 'You are unable to choose a game', + body: [ + { + type: 'TextBlock', + text: 'Would you like to play a game?', + size: 'Medium', + weight: 'Bolder', + }, + { + type: 'TextBlock', + text: 'Choose a game to play', + wrap: true, + }, + ], + actions: [ + { + type: 'Action.Submit', + title: 'Chess', + data: { verb: 'wopr_game', game: 'chess' }, + }, + { + type: 'Action.Submit', + title: "Falken's Maze", + data: { verb: 'wopr_game', game: 'maze' }, + }, + { + // Dangerous action — use Action.ShowCard for confirmation + type: 'Action.ShowCard', + title: 'Thermonuclear War', + card: { + type: 'AdaptiveCard', + body: [ + { + type: 'TextBlock', + text: "Are you sure? Wouldn't you prefer a good game of chess?", + wrap: true, + color: 'Attention', + }, + ], + actions: [ + { + type: 'Action.Submit', + title: 'Yes', + style: 'destructive', + data: { verb: 'wopr_game', game: 'war' }, + }, + // "No" simply collapses the ShowCard — no action needed + ], + }, + }, + ], +}; + +// Send the game card when the user says "play" +app.on('message', async ({ activity, send }) => { + if (activity.text?.match(/play/i)) { + await send({ + type: 'message', + attachments: [{ + contentType: 'application/vnd.microsoft.card.adaptive', + content: gameCard, + }], + }); + } +}); + +// Handle game selection (replaces app.attachmentAction("wopr_game")) +// TODO: Replace with app.adaptiveCards.actionSubmit if using teams-ai SDK +app.on('adaptiveCards.actionSubmit' as any, async ({ activity, send }) => { + const data = activity.value; + if (data?.verb === 'wopr_game') { + const game = data.game; + await send(`You chose: ${game}. Let's play!`); + // TODO: Send the follow-up card (replaces secondMessage / replace_original) + } +}); + +app.start(3978); +``` + +### Mapping reference table + +| Slack Legacy Attachment | Adaptive Card Equivalent | Notes | +|---|---|---| +| `attachments[].text` | `TextBlock` in `body` | Convert mrkdwn to standard Markdown | +| `attachments[].fallback` | Card-level `fallback` property | For notifications and accessibility | +| `attachments[].color` (`"good"`) | `Container` with `style: "good"` | Green styling | +| `attachments[].color` (`"warning"`) | `Container` with `style: "warning"` | Yellow styling | +| `attachments[].color` (`"danger"`) | `Container` with `style: "attention"` | Red styling | +| `attachments[].color` (`"#hex"`) | `Container` with `style: "emphasis"` | No arbitrary hex; use closest semantic style | +| `attachments[].callback_id` | `Action.Submit.data.verb` | Routing key for action handlers | +| `actions[].type: "button"` | `Action.Submit` | `name`/`value` → `data` keys | +| `actions[].style: "danger"` | `Action.Submit` with `style: "destructive"` | | +| `actions[].confirm` | `Action.ShowCard` with confirm sub-card | Or two-step confirmation flow | +| `actions[].type: "select"` | `Input.ChoiceSet` + `Action.Submit` | Requires explicit submit button | +| `attachment_type: "default"` | *(remove)* | No equivalent needed | +| `app.attachmentAction(id)` | `app.adaptiveCards.actionSubmit(verb)` | Or `app.on('adaptiveCards.actionSubmit')` | +| `respond({ replace_original })` | `updateActivity(activityId, card)` | Must store original activity ID | + +## pitfalls + +- **No arbitrary colors**: Slack attachments support any hex color via the `color` field. Adaptive Cards only support semantic styles (`"good"`, `"warning"`, `"attention"`, `"emphasis"`, `"accent"`, `"default"`). Map to the closest semantic meaning rather than exact color matching. +- **Confirmation dialogs require redesign**: Slack's `confirm` object is a built-in dialog. Adaptive Cards have no equivalent. `Action.ShowCard` is the closest — it reveals an inline sub-card. For a modal confirmation, use a task module flow instead. +- **Select fires differently**: Slack legacy selects fire immediately on selection. Adaptive Card `Input.ChoiceSet` requires a separate `Action.Submit` click. This changes the UX — inform users of the change. +- **Mixed attachments + blocks**: Some Slack messages combine legacy attachments with Block Kit blocks. Merge both into a single Adaptive Card. The attachment text becomes `TextBlock`s at the top, followed by the converted Block Kit elements. +- **`replace_original` requires activity ID**: Slack's `respond({ replace_original: true })` works with just the `response_url`. In Teams, you need the original activity ID to call `updateActivity()`. Store the activity ID when you send the card (returned from `send()`). +- **`callback_id` routing**: Slack routes attachment actions by `callback_id`. Teams routes by the `verb` (or custom key) in `Action.Submit.data`. Ensure every button includes a routing key in its `data` object. + +## references + +- https://api.slack.com/reference/messaging/attachments — Slack legacy attachments (deprecated but supported) +- https://api.slack.com/legacy/interactive-messages — Legacy interactive messages (attachment actions) +- https://adaptivecards.io/explorer/Action.ShowCard.html — Action.ShowCard (inline reveal) +- https://adaptivecards.io/explorer/Container.html — Container with style property +- https://learn.microsoft.com/en-us/microsoftteams/platform/task-modules-and-cards/cards/cards-reference — Teams card reference +- https://github.com/microsoft/teams.ts — Teams SDK v2 + +## instructions + +Use this expert when adding cross-platform support in either direction for Slack legacy attachments or Teams Adaptive Cards. It covers converting attachment JSON to Adaptive Card JSON and vice versa, mapping `attachmentAction` handlers to `actionSubmit` handlers, redesigning confirmation dialogs, handling message replacement, and dealing with mixed attachment + Block Kit messages. For Teams → Slack, Adaptive Cards can be mapped to legacy attachment format if targeting very old Slack integrations, though Block Kit is preferred. Pair with `ui-block-kit-adaptive-cards-ts.md` if the message also contains Block Kit blocks, and `../teams/ui.adaptive-cards-ts.md` for Adaptive Card construction patterns. + +## research + +Deep Research prompt: + +"Write a micro expert on bridging Slack legacy message attachments (pre-Block Kit) and Teams Adaptive Cards in either direction for cross-platform bots. Cover: attachment text/color/fallback/callback_id/actions mapping, button and select action conversion, confirm dialog redesign with Action.ShowCard, attachmentAction handler bridging to adaptiveCards.actionSubmit, replace_original to updateActivity, mixed attachments + Block Kit messages, color mapping limitations, and reverse-direction notes for Teams → Slack legacy attachment mapping. Include a worked example converting between formats." diff --git a/plugins/microsoft-365-agents-toolkit/skills/teams-app-developer/experts/bridge/ui-modals-dialogs-ts.md b/plugins/microsoft-365-agents-toolkit/skills/teams-app-developer/experts/bridge/ui-modals-dialogs-ts.md new file mode 100644 index 0000000..e72645e --- /dev/null +++ b/plugins/microsoft-365-agents-toolkit/skills/teams-app-developer/experts/bridge/ui-modals-dialogs-ts.md @@ -0,0 +1,451 @@ +# ui-modals-dialogs-ts + +## purpose + +Bridges Slack modal workflows and Teams task module / dialog flows for cross-platform bots targeting Slack, Teams, or both. + +## rules + +1. Slack `views.open(trigger_id, view)` maps to Teams `dialog.open` handler. In Slack, the app calls `ctx.client().viewsOpen()` with a `trigger_id` from a slash command or interaction. In Teams, the dialog opens when the user clicks an `Action.Submit` with `{ msteams: { type: 'task/fetch' } }` in its data, or from a manifest command. The `dialog.open` handler returns the card form. +2. Slack `app.viewSubmission(callback_id)` maps to Teams `app.on('dialog.submit', handler)`. Slack provides form data in `view.state.values[block_id][action_id]`; Teams provides it in `activity.value.data` as a flat object keyed by Adaptive Card input `id`s. +3. Slack `viewsUpdate` (updating the current modal) maps to returning a `continue` response from `dialog.submit` with a new card. Slack's `ctx.ack({ response_action: 'update', view: newView })` becomes returning `{ status: 200, body: { task: { type: 'continue', value: { title, card } } } }`. +4. Slack `views.push` (stacking a new modal) has no Teams equivalent. Teams task modules do not support stacking. Flatten multi-modal stacks into a single multi-step dialog with step routing in `dialog.submit`, or redesign as sequential cards in the chat. +5. Slack `app.viewClosed(callback_id)` (`notify_on_close: true`) has no direct Teams equivalent. Teams does not notify the bot when a user closes/cancels a task module. If cleanup is needed, handle it via timeout or the next user interaction. For critical cleanup, consider storing pending state and reconciling on the next bot message. +6. Slack field-level validation with `ctx.ackWithErrors({ block_id: "error message" })` (which keeps the modal open and shows inline errors) has no server-side equivalent in Teams. Use Adaptive Card client-side validation (`isRequired`, `errorMessage`, `regex`, `min`, `max`) for pre-submit validation. For server-side validation that fails, return a `continue` response with the form re-rendered including error `TextBlock`s, or return a `message` response with the error text. +7. Slack `private_metadata` (arbitrary string stored on the view) maps to embedding hidden state in `Action.Submit.data` fields. Include any round-trip state (original command args, IDs, step indicators) in the card's submit action `data` object. +8. Slack `blockSuggestion` (typeahead/external data source for selects inside modals) maps to Adaptive Card `Input.ChoiceSet` with `"style": "filtered"` for client-side filtering, or `Data.Query` with dynamic data source for server-side filtering (schema 1.6+, limited Teams support). For most cases, pre-populate the choices at dialog open time instead of dynamic fetching. +9. Slack `blockAction` inside modals (responding to user interactions mid-form without submitting) has no Teams equivalent. Adaptive Card inputs do not fire events until `Action.Submit` is clicked. If the Slack modal updated dynamically based on a selection, redesign as: (a) multi-step dialog (submit step 1, return step 2 card), or (b) pre-compute all variants and include conditional data in the initial card. +10. Slack modal `title`, `submit`, and `close` labels map to task module `title` (in the `value` object) and Adaptive Card `Action.Submit` button titles. There is no separate close button label — the task module always shows a platform X button. + +## patterns + +### Slash command → modal → submit (full flow) + +**Slack (before):** + +```kotlin +// Slash command opens a modal +app.command("/meeting") { _, ctx -> + val res = ctx.client().viewsOpen { + it.triggerId(ctx.triggerId).viewAsString(modalJson) + } + if (res.isOk) ctx.ack() + else Response.builder().statusCode(500).body(res.error).build() +} + +// Handle submission +app.viewSubmission("meeting-arrangement") { req, ctx -> + val stateValues = req.payload.view.state.values + val agenda = stateValues["agenda"]!!["agenda-input"]!!.value + val errors = mutableMapOf() + if (agenda.length <= 10) { + errors["agenda"] = "Agenda needs to be longer than 10 characters." + } + if (errors.isNotEmpty()) { + ctx.ackWithErrors(errors) + } else { + ctx.ack() + } +} + +// Handle close +app.viewClosed("meeting-arrangement") { _, ctx -> ctx.ack() } +``` + +**Teams (after):** + +```typescript +import { App } from '@microsoft/teams.apps'; +import { ConsoleLogger } from '@microsoft/teams.common'; + +const app = new App({ + logger: new ConsoleLogger('meeting-bot'), +}); + +// Step 1: Send a message with a button that triggers dialog.open +app.on('message', async ({ activity, send }) => { + if (activity.text?.match(/\/meeting/i)) { + await send({ + type: 'message', + attachments: [{ + contentType: 'application/vnd.microsoft.card.adaptive', + content: { + type: 'AdaptiveCard', + version: '1.5', + body: [{ type: 'TextBlock', text: 'Click below to arrange a meeting.' }], + actions: [{ + type: 'Action.Submit', + title: 'Arrange Meeting', + data: { msteams: { type: 'task/fetch' } }, + }], + }, + }], + }); + } +}); + +// Step 2: dialog.open returns the form card (replaces views.open) +app.on('dialog.open', async () => { + return { + status: 200, + body: { + task: { + type: 'continue', + value: { + title: 'Meeting Arrangement', + width: 'medium', + height: 'medium', + card: { + contentType: 'application/vnd.microsoft.card.adaptive', + content: { + type: 'AdaptiveCard', + version: '1.5', + body: [ + { + type: 'Input.Date', + id: 'meetingDate', + label: 'Meeting Date', + }, + { + type: 'Input.ChoiceSet', + id: 'topics', + label: 'Topics', + isMultiSelect: true, + style: 'filtered', + choices: [ + { title: 'Schedule', value: 'schedule' }, + { title: 'Budget', value: 'budget' }, + { title: 'Assignment', value: 'assignment' }, + ], + }, + { + type: 'Input.Text', + id: 'agenda', + label: 'Detailed Agenda', + isMultiline: true, + isRequired: true, + errorMessage: 'Agenda is required', + }, + ], + actions: [{ + type: 'Action.Submit', + title: 'Submit', + data: { action: 'meeting-arrangement' }, + }], + }, + }, + }, + }, + }, + }; +}); + +// Step 3: dialog.submit handles form data (replaces viewSubmission) +app.on('dialog.submit', async ({ activity }) => { + const data = activity.value.data; + const agenda: string = data.agenda ?? ''; + + // Server-side validation (replaces ctx.ackWithErrors) + if (agenda.length <= 10) { + // Return the form again with an error message + return { + status: 200, + body: { + task: { + type: 'continue', + value: { + title: 'Meeting Arrangement', + card: { + contentType: 'application/vnd.microsoft.card.adaptive', + content: { + type: 'AdaptiveCard', + version: '1.5', + body: [ + { + type: 'TextBlock', + text: 'Agenda needs to be longer than 10 characters.', + color: 'Attention', + weight: 'Bolder', + }, + // ... repeat form fields with previous values pre-filled ... + ], + actions: [{ + type: 'Action.Submit', + title: 'Submit', + data: { action: 'meeting-arrangement' }, + }], + }, + }, + }, + }, + }, + }; + } + + // Success — close the dialog + return { + status: 200, + body: { + task: { + type: 'message', + value: `Meeting arranged! Date: ${data.meetingDate}, Topics: ${data.topics}`, + }, + }, + }; +}); + +// Note: No viewClosed equivalent — Teams does not notify on dialog cancel. + +app.start(3978); +``` + +### Mapping reference table + +| Slack Modal Concept | Teams Dialog Equivalent | Notes | +|---|---|---| +| `views.open(trigger_id, view)` | `dialog.open` handler returning `continue` response | Triggered by `Action.Submit` with `msteams: { type: 'task/fetch' }` | +| `viewSubmission(callback_id)` | `dialog.submit` handler | Form data in `activity.value.data` (flat object) | +| `ctx.ack()` (close modal) | Return `{ task: { type: 'message', value } }` | Message shown briefly, then dialog closes | +| `ctx.ack({ response_action: 'update', view })` | Return `{ task: { type: 'continue', value: { card } } }` | Replaces dialog content | +| `ctx.ack({ response_action: 'push', view })` | *(no equivalent)* | Flatten into multi-step dialog | +| `ctx.ackWithErrors(errors)` | Return `continue` with error TextBlocks, or use client-side validation | No native field-level error API | +| `viewClosed(callback_id)` | *(no equivalent)* | Teams does not notify on cancel | +| `private_metadata` | `Action.Submit.data` fields | Embed state in submit action | +| `view.state.values[block_id][action_id]` | `activity.value.data[inputId]` | Flat key-value vs nested structure | +| `blockSuggestion` (typeahead) | `Input.ChoiceSet` with `style: "filtered"` | Client-side only; pre-populate choices | +| `blockAction` mid-form | *(no equivalent)* | Redesign as multi-step dialog | +| Modal `title` / `submit` / `close` labels | `value.title` + `Action.Submit.title` | No custom close label | + +### Dynamic selects best practice (Y9) + +Pre-populate `Input.ChoiceSet` with `style: "filtered"` for datasets under 500 items. For larger datasets, use a two-step dialog. + +```typescript +// Small dataset (<500 items): pre-populate with client-side filtering +function buildSelectCard(users: { name: string; email: string }[]): object { + return { + type: "AdaptiveCard", version: "1.5", + body: [{ + type: "Input.ChoiceSet", + id: "user_select", + label: "Assign to", + style: "filtered", // enables client-side typeahead search + choices: users.map(u => ({ title: u.name, value: u.email })), + }], + actions: [{ type: "Action.Submit", title: "Assign", data: { action: "assign" } }], + }; +} + +// Large dataset (>500 items): two-step dialog +// Step 1: text input for search query +function buildSearchStep(): object { + return { + type: "AdaptiveCard", version: "1.5", + body: [{ + type: "Input.Text", id: "search_query", + label: "Search users", placeholder: "Type a name...", + }], + actions: [{ type: "Action.Submit", title: "Search", data: { action: "search_users", step: 1 } }], + }; +} + +// Step 2: submit handler queries server, returns filtered ChoiceSet +app.on("dialog.submit", async ({ activity }) => { + const data = activity.value.data; + if (data?.action === "search_users" && data.step === 1) { + const results = await searchUsers(data.search_query); // server-side query + return { + status: 200, + body: { task: { type: "continue", value: { + title: "Select User", + card: { + contentType: "application/vnd.microsoft.card.adaptive", + content: buildSelectCard(results), // now a small filtered set + }, + }}}, + }; + } +}); +``` + +**Don't:** Build a web-based task module just for a searchable dropdown. The effort (16–24 hrs) rarely justifies the marginal UX improvement over two-step. + +**Reverse (Teams → Slack):** Use `external_data_source: true` on select elements with `app.options()` for server-side typeahead. + +### Cancel detection workaround: TTL + Cancel button (R3) + +Teams does not notify the bot when a dialog is dismissed. Add an explicit "Cancel" button and a timeout to handle cleanup. + +```typescript +// Track pending dialog state with TTL +const pendingDialogs = new Map(); + +// When opening a dialog, record the pending state +app.on('dialog.open', async ({ activity }) => { + const userId = activity.from?.aadObjectId ?? ''; + const dialogId = `dlg_${Date.now()}`; + pendingDialogs.set(dialogId, { + userId, + lockedResource: 'ticket-123', + expiresAt: Date.now() + 5 * 60_000, // 5-minute TTL + }); + return { + status: 200, + body: { + task: { + type: 'continue', + value: { + title: 'Edit Ticket', + card: buildFormCard(dialogId), // embed dialogId in Action.Submit.data + }, + }, + }, + }; +}); + +// Handle explicit Cancel button (inside the dialog) +app.on('dialog.submit', async ({ activity }) => { + const data = activity.value.data; + if (data?.action === 'cancel') { + pendingDialogs.delete(data.dialogId); + releaseLock(data.dialogId); + return { status: 200, body: { task: { type: 'message', value: 'Cancelled.' } } }; + } + // Handle normal submit... + pendingDialogs.delete(data.dialogId); + return { status: 200, body: { task: { type: 'message', value: 'Saved!' } } }; +}); + +// Periodic cleanup of expired dialogs (user closed without clicking Cancel) +setInterval(() => { + const now = Date.now(); + for (const [id, state] of pendingDialogs) { + if (state.expiresAt < now) { + releaseLock(id); + pendingDialogs.delete(id); + } + } +}, 60_000); // check every minute +``` + +**Reverse (Teams → Slack):** Use `notify_on_close: true` in `views.open()` and handle `viewClosed` natively. + +### Multi-step dialog workaround: step routing (R4/R6) + +Replace Slack's `views.push()` stacking and `dispatch_action` mid-form updates with a single dialog using step routing. + +```typescript +app.on('dialog.submit', async ({ activity }) => { + const data = activity.value.data; + const step = data?.step ?? 1; + + if (data?.action === 'back') { + return buildStepResponse(step - 1, data); + } + if (data?.action === 'next') { + // Validate current step + const errors = validateStep(step, data); + if (errors.length > 0) { + return buildStepResponse(step, data, errors); // re-render with errors (R5) + } + if (step >= 3) { + // Final step — process all data + await processWizard(data); + return { status: 200, body: { task: { type: 'message', value: 'Done!' } } }; + } + return buildStepResponse(step + 1, data); + } +}); + +function buildStepResponse(step: number, previousData: Record, errors: string[] = []) { + return { + status: 200, + body: { + task: { + type: 'continue', + value: { + title: `Step ${step} of 3`, + card: { + contentType: 'application/vnd.microsoft.card.adaptive', + content: { + type: 'AdaptiveCard', version: '1.5', + body: [ + // Show validation errors if any (R5 workaround) + ...errors.map(e => ({ + type: 'TextBlock', text: e, color: 'Attention', weight: 'Bolder', + })), + // Step-specific fields + ...getStepFields(step, previousData), + ], + actions: [ + ...(step > 1 ? [{ + type: 'Action.Submit', title: 'Back', + data: { ...previousData, step, action: 'back' }, + }] : []), + { + type: 'Action.Submit', + title: step === 3 ? 'Finish' : 'Next', + data: { ...previousData, step, action: 'next' }, + }, + { + type: 'Action.Submit', title: 'Cancel', + data: { ...previousData, step, action: 'cancel' }, + }, + ], + }, + }, + }, + }, + }, + }; +} +``` + +**Key principle:** Every step's `Action.Submit.data` must carry forward ALL data from previous steps, since there's no persistent modal state like Slack's `private_metadata`. + +**Reverse (Teams → Slack):** Use `views.push()` for stacking (up to 3 levels) and `dispatch_action: true` + `views.update()` for mid-form dynamics. + +### Reverse direction (Teams → Slack) + +For Teams → Slack, map `dialog.open` to `views.open` with `trigger_id`, `dialog.submit` to `viewSubmission`, and Adaptive Card inputs to Block Kit inputs. Key reverse mappings: +- `dialog.open` handler returning `continue` → `views.open(trigger_id, view)` -- note: Slack requires a `trigger_id` from a preceding interaction (slash command, button click, etc.) +- `dialog.submit` handler → `app.view('callback_id', ...)` with `view.state.values[block_id][action_id]` +- `activity.value.data[inputId]` (flat) → `view.state.values[block_id][action_id].value` (nested) +- Return `{ task: { type: 'continue', value: { card } } }` → `ctx.ack({ response_action: 'update', view: newView })` +- Return `{ task: { type: 'message', value } }` → `ctx.ack()` (close modal) +- Multi-step dialog (routing by `data.step`) → `views.push` for stacked modals (Slack supports stacking) +- Error `TextBlock` re-render → `ctx.ackWithErrors({ block_id: 'error message' })` for inline field-level errors +- `Action.Submit.data` hidden fields → `private_metadata` string on the view +- `Input.ChoiceSet` with `style: "filtered"` → `blockSuggestion` handler for server-side typeahead +- Adaptive Card `isRequired`/`errorMessage`/`regex` client-side validation → server-side validation in `viewSubmission` with `ackWithErrors` +- No cancel notification (Teams) → `viewClosed(callback_id)` with `notify_on_close: true` (Slack supports cancel callbacks) + +## pitfalls + +- **No modal stacking**: Slack's `views.push` stacks modals. Teams task modules cannot stack. Redesign stacked flows as multi-step forms within a single dialog (route by `data.step` in the submit handler). +- **No cancel notification**: Slack's `viewClosed` handler fires when a user clicks Cancel (with `notify_on_close: true`). Teams has no equivalent. Do not rely on cancel callbacks for critical state cleanup. +- **Validation UX is different**: Slack's `ackWithErrors` shows inline red text under specific fields and keeps the modal open. Teams has no server-side field-level error API. Use Adaptive Card `isRequired`/`errorMessage`/`regex` for client-side checks. For server-side failures, return a `continue` response with an error `TextBlock` added to the card. +- **Form data structure change**: Slack nests form data as `view.state.values[block_id][action_id].value`. Teams flattens it as `activity.value.data[inputId]`. The nesting is gone — input `id`s must be unique across the entire card. +- **Trigger mechanism change**: Slack opens modals from `trigger_id` (passed in slash command and interaction payloads). Teams opens dialogs from `Action.Submit` with `msteams: { type: 'task/fetch' }` or from manifest commands. There is no free-standing "open dialog" API call. +- **Dynamic selects**: Slack's `blockSuggestion` fires on each keystroke to fetch options server-side. Adaptive Card `Input.ChoiceSet` with `style: "filtered"` only filters pre-populated choices client-side. For truly dynamic data, pre-fetch at dialog open time or use `Data.Query` (limited support). +- **Mid-form interactions lost**: Slack modals can respond to `blockAction` events mid-form (e.g., showing/hiding fields based on a dropdown). Adaptive Cards do not fire events until submit. Redesign conditional forms as multi-step dialogs. +- **Returning nothing closes with error**: If the `dialog.submit` handler returns `undefined`, Teams shows a generic error. Always return a valid `{ status: 200, body: { task: { ... } } }` response. + +## references + +- https://api.slack.com/surfaces/modals — Slack modal documentation +- https://api.slack.com/surfaces/modals/using#pushing — Stacking views with views.push +- https://api.slack.com/surfaces/modals/using#closing — notify_on_close and viewClosed +- https://api.slack.com/reference/interaction-payloads/views — view_submission payload +- https://learn.microsoft.com/en-us/microsoftteams/platform/task-modules-and-cards/task-modules/task-modules-bots — Teams task modules +- https://github.com/microsoft/teams.ts — Teams SDK v2 + +## instructions + +Use this expert when bridging Slack modal workflows and Teams dialog/task module flows in either direction. It covers the full lifecycle: opening (`views.open` ↔ `dialog.open`), submission (`viewSubmission` ↔ `dialog.submit`), updating (`response_action: update` ↔ `continue` response), stacking (`views.push` ↔ multi-step redesign), closing (`viewClosed` ↔ no Teams equivalent), validation (`ackWithErrors` ↔ client-side + `continue`), and dynamic selects (`blockSuggestion` ↔ filtered `ChoiceSet`). Use when adding cross-platform support in either direction. Pair with `ui-block-kit-adaptive-cards-ts.md` for converting modal Block Kit to Adaptive Card elements (or vice versa), `../teams/ui.dialogs-task-modules-ts.md` for Teams-side dialog patterns, and `../teams/ui.adaptive-cards-ts.md` for card construction. + +## research + +Deep Research prompt: + +"Write a micro expert for bridging Slack modals and Teams task modules / dialogs bidirectionally. Cover views.open <-> dialog.open, viewSubmission <-> dialog.submit, viewsUpdate <-> continue response, viewClosed <-> no equivalent, blockSuggestion <-> filtered ChoiceSet, blockAction <-> no equivalent, ackWithErrors <-> client-side validation, private_metadata <-> Action.Submit.data, and notify_on_close. Include a comprehensive bidirectional mapping table, a full worked example showing both directions, and pitfalls around stacking, validation, cancel notification, and dynamic selects." diff --git a/plugins/microsoft-365-agents-toolkit/skills/teams-app-developer/experts/bridge/workflow.composable-platform-ts.md b/plugins/microsoft-365-agents-toolkit/skills/teams-app-developer/experts/bridge/workflow.composable-platform-ts.md new file mode 100644 index 0000000..fe42b55 --- /dev/null +++ b/plugins/microsoft-365-agents-toolkit/skills/teams-app-developer/experts/bridge/workflow.composable-platform-ts.md @@ -0,0 +1,297 @@ +# workflow.composable-platform-ts + +## purpose + +Architectural guide for building a composable, reusable workflow operating layer inside Teams — the five-element framework (trigger, state, logic, intelligence, visibility) as a platform pattern, not a point solution. + +## rules + +1. **Every workflow follows the same five-element lifecycle.** (1) Trigger — how it starts, (2) State — where records live, (3) Logic — how decisions and automation execute, (4) Intelligence — how AI is layered over state, (5) Visibility — how records remain embedded in channels. Design every workflow as an instantiation of this lifecycle. +2. **Define workflows as configuration, not code.** A workflow definition specifies: trigger type + parameters, list schema (columns and types), routing rules (approval chain, auto-assign), query functions (NL schemas), and card templates (active/completed/error). The runtime consumes these definitions generically. +3. **Use a `WorkflowDefinition` interface as the core abstraction.** This interface describes the workflow's schema, triggers, routing, and card templates. The runtime registers handlers dynamically from definitions. New workflows require a new definition object, not new handler code. +4. **Template workflows are reference implementations.** Provide polished, out-of-the-box definitions for common scenarios: time-off requests, equipment booking, daily standup, account health. These serve as both usable workflows and examples for customization. +5. **The runtime is a generic workflow engine.** A single set of handlers (message, `card.action`, proactive, webhooks) dispatch to the correct workflow based on the verb/command prefix in the message or action data. The engine creates records, processes actions, and renders cards for any registered workflow. +6. **SharePoint Lists are the default state backend.** Each workflow definition maps to a SharePoint list. The engine creates lists on first use, following the schema in the definition. For enterprise needs, swap to Dataverse without changing the workflow definition. +7. **Card templates are parameterized, not hardcoded.** Define card templates as functions that take a record and return an Adaptive Card. The workflow definition includes templates for: `activeCard`, `completedCard`, `listCard`, and `formCard`. The engine calls the right template based on record state. +8. **Query functions are auto-generated from the schema.** Given a workflow definition's column schema, generate AI function-calling schemas automatically: each filterable column becomes a parameter. This eliminates writing per-workflow query functions manually. +9. **Extensibility points for ecosystem partners.** The composable platform should expose: (a) custom trigger types (plugin new event sources), (b) custom logic steps (plugin business rules), (c) custom card templates (brand and layout), (d) custom state backends (plugin storage). Each point has a defined interface. +10. **Cross-workflow queries are first-class.** The engine registers a `queryAnyWorkflow` function that searches across all registered workflow lists. Users ask "what's overdue?" and get results from PTO, equipment, and standup workflows combined. +11. **Power Automate integration is optional, not required.** The composable platform can execute logic in-bot (state machine) or delegate to Power Automate flows. Workflow definitions specify `executionMode: "bot" | "powerAutomate" | "hybrid"`. Bot mode is the default for SMB; Power Automate mode for enterprise. + +## patterns + +### WorkflowDefinition interface + +```typescript +interface WorkflowDefinition { + id: string; // Unique workflow identifier + name: string; // Display name + description: string; // Used in command suggestions and AI descriptions + commandPrefix: string; // e.g., "/pto", "/book", "/standup" + + // Schema + columns: ColumnDefinition[]; // Maps to SharePoint List columns + statusField: string; // Which column tracks lifecycle state + statusValues: { + active: string[]; // e.g., ["Pending", "InProgress"] + completed: string[]; // e.g., ["Approved", "Rejected", "Done"] + }; + + // Triggers + triggers: TriggerConfig[]; + + // Routing + routing?: { + type: "none" | "single" | "sequential" | "parallel-any" | "parallel-all"; + approverSource: "fixed" | "manager" | "field"; // Where to find the approver + approverField?: string; // Column name if approverSource is "field" + escalationTimeoutMs?: number; + }; + + // Cards + cards: { + active: (record: any) => object; + completed: (record: any) => object; + list: (records: any[]) => object; + form?: () => object; // For message extension action trigger + }; + + // AI + queryDescription: string; // Describes when AI should call the query function + filterableColumns: string[]; // Columns exposed as AI function parameters +} + +interface ColumnDefinition { + name: string; + type: "text" | "number" | "dateTime" | "choice" | "personOrGroup" | "boolean"; + choices?: string[]; // For choice columns + required?: boolean; +} + +interface TriggerConfig { + type: "command" | "messageExtension" | "scheduled" | "stateChange"; + config: Record; // Trigger-specific configuration +} +``` + +### Register a workflow from a definition + +```typescript +function registerWorkflow(app: any, engine: WorkflowEngine, definition: WorkflowDefinition) { + // Command trigger + const commandTrigger = definition.triggers.find((t) => t.type === "command"); + if (commandTrigger) { + const regex = new RegExp(`^\\${definition.commandPrefix}\\s*(.*)$`, "i"); + app.message(regex, async (ctx: any) => { + await engine.handleCommand(ctx, definition); + }); + } + + // Scheduled trigger + const scheduledTrigger = definition.triggers.find((t) => t.type === "scheduled"); + if (scheduledTrigger) { + cron.schedule(scheduledTrigger.config.cron, async () => { + await engine.handleScheduled(definition); + }); + } + + // Register AI query function + engine.registerQueryFunction(definition); +} +``` + +### Generic workflow engine + +```typescript +class WorkflowEngine { + private definitions = new Map(); + private graphClient: Client; + private siteId: string; + private lists = new Map(); // workflowId -> listId + + async handleCommand(ctx: any, def: WorkflowDefinition) { + const params = parseCommandParams(ctx.activity.text!, def); + const record = await this.createRecord(def, { + ...params, + requesterId: ctx.activity.from?.aadObjectId, + requesterName: ctx.activity.from?.name, + conversationId: ctx.activity.conversation?.id, + serviceUrl: ctx.activity.serviceUrl, + }); + + const card = def.cards.active(record); + const response = await ctx.send({ + attachments: [{ + contentType: "application/vnd.microsoft.card.adaptive", + content: card, + }], + }); + + // Store activity ID for future updates + await this.updateRecordField(def, record.id, "CardActivityId", response.id); + + // Start escalation timer if routing is configured + if (def.routing?.escalationTimeoutMs) { + this.startEscalation(def, record); + } + } + + async handleAction(ctx: any, verb: string, data: any) { + const def = this.definitions.get(data.workflowId); + if (!def) return; + + const record = await this.getRecord(def, data.recordId); + + if (verb === "approve" || verb === "reject") { + return this.processApproval(ctx, def, record, verb, data.comment); + } + + if (verb.startsWith("refresh")) { + const card = record.status === "completed" + ? def.cards.completed(record) + : def.cards.active(record); + return { + status: 200, + body: { + statusCode: 200, + type: "application/vnd.microsoft.card.adaptive", + value: card, + }, + }; + } + } + + registerQueryFunction(def: WorkflowDefinition) { + // Auto-generate AI function schema from definition + const parameters: Record = {}; + for (const col of def.filterableColumns) { + const colDef = def.columns.find((c) => c.name === col); + if (!colDef) continue; + + switch (colDef.type) { + case "choice": + parameters[col] = { type: "string", enum: colDef.choices }; + break; + case "dateTime": + parameters[col] = { type: "string", description: `Filter by ${col} (ISO date)` }; + break; + case "personOrGroup": + parameters[col] = { type: "string", description: `Filter by ${col} name` }; + break; + default: + parameters[col] = { type: "string" }; + } + } + + return { + name: `query_${def.id}`, + description: def.queryDescription, + parameters: { type: "object", properties: parameters }, + }; + } + + private async createRecord(def: WorkflowDefinition, fields: Record) { + const listId = await this.ensureList(def); + const item = await this.graphClient + .api(`/sites/${this.siteId}/lists/${listId}/items`) + .post({ fields }); + return { id: item.id, ...item.fields }; + } + + private async ensureList(def: WorkflowDefinition): Promise { + if (this.lists.has(def.id)) return this.lists.get(def.id)!; + + // Check if list exists, create if not + try { + const existing = await this.graphClient + .api(`/sites/${this.siteId}/lists`) + .filter(`displayName eq '${def.name}'`) + .get(); + + if (existing.value.length > 0) { + this.lists.set(def.id, existing.value[0].id); + return existing.value[0].id; + } + } catch { /* List doesn't exist */ } + + const list = await this.graphClient + .api(`/sites/${this.siteId}/lists`) + .post({ + displayName: def.name, + list: { template: "genericList" }, + columns: def.columns.map(colDefToGraphColumn), + }); + + this.lists.set(def.id, list.id); + return list.id; + } +} +``` + +### Template workflow: Time-Off Request + +```typescript +const ptoWorkflow: WorkflowDefinition = { + id: "pto", + name: "PTO Requests", + description: "Time-off and vacation request workflow", + commandPrefix: "/pto", + columns: [ + { name: "Requester", type: "personOrGroup", required: true }, + { name: "StartDate", type: "dateTime", required: true }, + { name: "EndDate", type: "dateTime", required: true }, + { name: "HoursRequested", type: "number" }, + { name: "Status", type: "choice", choices: ["Pending", "Approved", "Rejected"] }, + { name: "ApprovedBy", type: "personOrGroup" }, + { name: "Reason", type: "text" }, + ], + statusField: "Status", + statusValues: { + active: ["Pending"], + completed: ["Approved", "Rejected"], + }, + triggers: [ + { type: "command", config: { pattern: "/pto START to END" } }, + { type: "messageExtension", config: { commandId: "createPto" } }, + ], + routing: { + type: "single", + approverSource: "manager", + escalationTimeoutMs: 48 * 60 * 60 * 1000, // 48 hours + }, + cards: { + active: buildPtoActiveCard, + completed: buildPtoCompletedCard, + list: buildPtoListCard, + }, + queryDescription: "Query PTO/time-off requests. Use when user asks about PTO, vacation, leave, days off.", + filterableColumns: ["Status", "Requester", "StartDate"], +}; + +// Register +registerWorkflow(app, engine, ptoWorkflow); +``` + +## pitfalls + +- **Over-abstraction kills velocity.** The composable platform should start with 2-3 template workflows and extract common patterns. Don't build the full generic engine before validating with real workflows. +- **Schema migrations are hard.** Once a SharePoint List is created, adding required columns or changing types is disruptive. Version your schemas and handle missing columns gracefully. +- **Generic engines produce generic cards.** Template card functions should be polished, not auto-generated. The best workflow UX comes from purpose-built card layouts, not generic field renderers. +- **Power Automate hybrid mode adds complexity.** Supporting both bot-native and Power Automate execution means two code paths, two monitoring surfaces, and two failure modes. Default to bot-native for the FHL; add Power Automate later. +- **Ecosystem extensibility requires stable interfaces.** Don't expose extension points until the core patterns stabilize through 3+ real workflow implementations. + +## references + +- https://learn.microsoft.com/en-us/graph/api/resources/list +- https://learn.microsoft.com/en-us/microsoftteams/platform/task-modules-and-cards/cards/universal-actions-for-adaptive-cards/overview +- https://learn.microsoft.com/en-us/power-automate/getting-started + +## instructions + +Use this expert when designing the overall composable workflow architecture. Covers the five-element lifecycle framework, WorkflowDefinition interface, generic engine patterns, template workflows, auto-generated AI query schemas, and extensibility points. Pair with `../teams/workflow.sharepoint-lists-ts.md` for state persistence, `../teams/workflow.message-native-records-ts.md` for card-as-record patterns, `../teams/workflow.triggers-compose-ts.md` for trigger unification, `../teams/ai.conversational-query-ts.md` for NL retrieval, and `../teams/workflow.approvals-inline-ts.md` for approval routing. + +## research + +Deep Research prompt: + +"Write a micro expert on designing a composable workflow platform inside Microsoft Teams (TypeScript). Cover: five-element lifecycle framework (trigger, state, logic, intelligence, visibility), WorkflowDefinition configuration interface, generic workflow engine that dispatches from definitions, template/reference workflows (PTO, equipment, standup), auto-generated AI function schemas from column definitions, SharePoint Lists as pluggable state backend, Power Automate hybrid execution mode, and ecosystem extensibility points. Include complete patterns for the definition interface, engine registration, and one template workflow." diff --git a/plugins/microsoft-365-agents-toolkit/skills/teams-app-developer/experts/bridge/workflows-automation-ts.md b/plugins/microsoft-365-agents-toolkit/skills/teams-app-developer/experts/bridge/workflows-automation-ts.md new file mode 100644 index 0000000..370d026 --- /dev/null +++ b/plugins/microsoft-365-agents-toolkit/skills/teams-app-developer/experts/bridge/workflows-automation-ts.md @@ -0,0 +1,247 @@ +# workflows-automation-ts + +## purpose + +Bridges Slack Workflow Builder and Teams Power Automate / bot-driven orchestration for cross-platform bots targeting Slack, Teams, or both. + +## rules + +1. **Slack Workflow Builder → Power Automate flows (manual rebuild required).** There is no automated migration tool. Slack workflows are drag-and-drop automations with triggers and steps. Power Automate flows serve the same purpose but with a completely different builder, trigger system, and step library. Each workflow must be manually recreated. [learn.microsoft.com -- Power Automate](https://learn.microsoft.com/en-us/power-automate/getting-started) +2. **Slack workflow triggers → Power Automate triggers.** Slack triggers include: webhook, shortcut, new channel message, emoji reaction, user joins channel. Power Automate equivalents: HTTP request (webhook), Teams message trigger, approval trigger, Recurrence (scheduled), and 400+ connectors. Map each trigger individually. [learn.microsoft.com -- Triggers](https://learn.microsoft.com/en-us/power-automate/triggers-introduction) +3. **Slack custom steps (`workflow_step_execute`) → Power Automate custom connectors.** Slack bots can register custom workflow steps that appear in the Workflow Builder. In Power Automate, the equivalent is a custom connector wrapping your bot's REST API. The connector defines actions, inputs, and outputs that appear in the flow designer. [learn.microsoft.com -- Custom connectors](https://learn.microsoft.com/en-us/connectors/custom-connectors/) +4. **Slack approval workflows → Power Automate Approvals connector (built-in).** Slack workflows that collect approvals via emoji reactions or form submissions map to Power Automate's native Approvals connector. It provides: approval request creation, approval/rejection actions, parallel/sequential approvals, and approval history. No custom code needed. [learn.microsoft.com -- Approvals](https://learn.microsoft.com/en-us/power-automate/get-started-approvals) +5. **Teams "Workflows" app provides simple in-Teams automations.** For basic workflows (post to channel on schedule, notify on form submission), the Workflows app in Teams provides templates without leaving Teams. It's powered by Power Automate under the hood but has a simplified UI. [learn.microsoft.com -- Workflows app](https://learn.microsoft.com/en-us/microsoftteams/platform/m365-apps/publish-app#workflows) +6. **Bot-driven workflow alternative: state machine + Adaptive Card buttons.** For workflows that don't fit Power Automate's model (complex branching, dynamic participants, long-running multi-step processes), implement a state machine in the bot. Each step sends an Adaptive Card with action buttons; button clicks advance the state. Store workflow state in Cosmos DB or similar. [github.com/microsoft/teams.ts](https://github.com/microsoft/teams.ts) +7. **Slack `workflow_step` event lifecycle → custom connector action lifecycle.** Slack's workflow step has `edit` (configure step), `save` (persist config), `execute` (run step). Power Automate custom connectors define: action schema (inputs/outputs in OpenAPI), and the runtime HTTP call. There is no separate "edit" flow — the connector schema defines the UI. [learn.microsoft.com -- Connector actions](https://learn.microsoft.com/en-us/connectors/custom-connectors/define-blank#define-the-action) +8. **Slack workflow variables → Power Automate dynamic content.** Slack workflows pass data between steps via variables set in earlier steps. Power Automate uses "dynamic content" — outputs from previous steps that can be referenced in later steps. The data flow model is similar but the syntax is completely different. [learn.microsoft.com -- Dynamic content](https://learn.microsoft.com/en-us/power-automate/use-expressions-in-conditions) +9. **Power Automate flows can call Bot Framework via HTTP.** To integrate your Teams bot into a Power Automate flow, expose REST endpoints on your bot's server and call them from Power Automate's HTTP action. The bot can then send proactive messages based on flow triggers. [learn.microsoft.com -- HTTP connector](https://learn.microsoft.com/en-us/connectors/custom-connectors/) +10. **Slack Workflow Builder is free; Power Automate has licensing tiers.** Slack Workflow Builder is included in all plans. Power Automate has a free tier (limited runs) and premium tiers. Custom connectors require a premium license. Factor licensing into migration planning. [learn.microsoft.com -- Power Automate licensing](https://learn.microsoft.com/en-us/power-platform/admin/pricing-billing-skus) +11. **Reverse direction (Teams → Slack):** For Teams → Slack, Power Automate flows can be mapped to Slack Workflow Builder steps or custom `workflow_step_execute` handlers. Power Automate Approvals map to Slack approval workflows using emoji reactions or interactive message buttons. Power Automate custom connectors map to Slack custom workflow steps registered via `workflow_step` events. Power Automate Recurrence triggers map to Slack Workflow Builder scheduled triggers. + +## patterns + +### Approval workflow → Power Automate Approvals + +**Slack Workflow Builder (before):** + +The Slack workflow is configured in the GUI: +1. Trigger: User submits a form (custom step) +2. Step 1: Send form data to `#approvals` channel +3. Step 2: Wait for `:white_check_mark:` reaction from approver +4. Step 3: Post result to `#completed` channel + +**Bot code for custom approval step:** + +```typescript +import { App } from "@slack/bolt"; + +const app = new App({ + token: process.env.SLACK_BOT_TOKEN!, + signingSecret: process.env.SLACK_SIGNING_SECRET!, +}); + +// Custom workflow step execution +app.event("workflow_step_execute", async ({ event, client }) => { + const { workflow_step } = event; + const inputs = workflow_step.inputs; + + // Post approval request + const msg = await client.chat.postMessage({ + channel: "#approvals", + text: `Approval needed: ${inputs.request_text.value}`, + blocks: [ + { + type: "section", + text: { type: "mrkdwn", text: `*Approval Request*\n${inputs.request_text.value}` }, + }, + { type: "section", text: { type: "mrkdwn", text: "React with :white_check_mark: to approve or :x: to reject." } }, + ], + }); + + // Watch for reaction (simplified — real implementation uses reaction_added event) +}); +``` + +**Teams (after) — Power Automate flow (described as JSON definition):** + +```json +{ + "definition": { + "triggers": { + "manual": { + "type": "Request", + "kind": "Button", + "inputs": { + "schema": { + "type": "object", + "properties": { + "requestText": { "type": "string", "title": "Request details" }, + "requesterEmail": { "type": "string", "title": "Requester email" } + } + } + } + } + }, + "actions": { + "Start_approval": { + "type": "OpenApiConnection", + "inputs": { + "host": { "connectionName": "shared_approvals" }, + "operationId": "StartAndWaitForAnApproval", + "parameters": { + "approvalType": "Basic", + "ApprovalCreationInput/title": "Approval: @{triggerBody()?['requestText']}", + "ApprovalCreationInput/assignedTo": "approver@company.com", + "ApprovalCreationInput/details": "@{triggerBody()?['requestText']}" + } + } + }, + "Post_result_to_Teams": { + "type": "OpenApiConnection", + "inputs": { + "host": { "connectionName": "shared_teams" }, + "operationId": "PostMessageToConversation", + "parameters": { + "poster": "Flow bot", + "location": "Channel", + "body/recipient": "completed-channel-id", + "body/messageBody": "Request @{outputs('Start_approval')?['body/title']} was @{outputs('Start_approval')?['body/outcome']}" + } + }, + "runAfter": { "Start_approval": ["Succeeded"] } + } + } + } +} +``` + +**Bot-driven alternative (for complex approval logic):** + +```typescript +import { App } from "@microsoft/teams.apps"; +import { ConsoleLogger } from "@microsoft/teams.common"; + +const app = new App({ + logger: new ConsoleLogger("my-bot", { level: "info" }), +}); + +// Approval state machine +interface ApprovalRequest { + id: string; + text: string; + requester: string; + status: "pending" | "approved" | "rejected"; + activityId?: string; +} + +const approvals = new Map(); + +// Create approval request +app.message(/^\/?approve (.+)$/i, async ({ send, activity }) => { + const text = activity.text?.replace(/^\/?approve\s+/i, "") ?? ""; + const id = `apr_${Date.now()}`; + const approval: ApprovalRequest = { + id, + text, + requester: activity.from?.name ?? "Unknown", + status: "pending", + }; + approvals.set(id, approval); + + const response = await send({ + attachments: [{ + contentType: "application/vnd.microsoft.card.adaptive", + content: { + type: "AdaptiveCard", + version: "1.5", + body: [ + { type: "TextBlock", text: "Approval Request", weight: "Bolder", size: "Medium" }, + { type: "TextBlock", text: `**From:** ${approval.requester}`, wrap: true }, + { type: "TextBlock", text: approval.text, wrap: true }, + ], + actions: [ + { type: "Action.Execute", title: "Approve", verb: "approveAction", data: { approvalId: id } }, + { type: "Action.Execute", title: "Reject", verb: "rejectAction", data: { approvalId: id } }, + ], + }, + }], + }); +}); + +// Handle approval/rejection buttons +app.on("card.action" as any, async ({ activity }) => { + const data = activity.value?.action?.data ?? activity.value; + const approval = approvals.get(data?.approvalId); + + if (!approval) return { status: 200, body: {} }; + + const isApprove = data?.verb === "approveAction"; + approval.status = isApprove ? "approved" : "rejected"; + const reviewer = activity.from?.name ?? "Someone"; + + // Return updated card (replaces original) + return { + status: 200, + body: { + type: "AdaptiveCard", + version: "1.5", + body: [ + { type: "TextBlock", text: "Approval Request", weight: "Bolder", size: "Medium" }, + { type: "TextBlock", text: approval.text, wrap: true }, + { + type: "TextBlock", + text: `**${approval.status.toUpperCase()}** by ${reviewer}`, + color: isApprove ? "Good" : "Attention", + weight: "Bolder", + }, + ], + // No actions — card is now read-only + }, + }; +}); + +app.start(3978); +``` + +### Migration approach comparison + +| Slack Workflow Feature | Power Automate | Bot-Driven | Teams Workflows App | +|---|---|---|---| +| Visual builder | Yes (full designer) | No (code) | Yes (simplified) | +| Custom steps | Custom connectors | Handler code | No | +| Approval flows | Built-in Approvals | Card buttons + state | No | +| Scheduled triggers | Recurrence trigger | Timer + proactive | Yes (basic) | +| Complex branching | Yes (conditions, loops) | State machine | No | +| License cost | Free tier + Premium | Bot hosting cost | Free | +| Developer skill needed | Low-code | TypeScript | None | + +## pitfalls + +- **No automated migration**: Every Slack workflow must be manually recreated in Power Automate or bot code. There is no import/export compatibility. Plan for significant manual effort on large workflow portfolios. +- **Reaction-based approvals break completely**: Slack workflows commonly use emoji reactions as approval signals. Teams has no equivalent pattern in Power Automate. Use the built-in Approvals connector or Action.Execute card buttons. +- **Custom connector licensing**: Power Automate custom connectors (needed to replace Slack custom workflow steps) require a Premium license. The free tier does not support custom connectors. +- **Slack workflow variables vs Power Automate dynamic content**: The data passing model is similar in concept but completely different in syntax. Slack uses `{{variable_name}}`; Power Automate uses `@{outputs('step_name')?['property']}`. This is a manual translation. +- **Bot-driven workflows require state persistence**: Unlike Power Automate which manages state internally, bot-driven approval workflows need external state storage (Cosmos DB, SQL). Without it, workflow state is lost on bot restart. +- **Power Automate flow limits**: Free tier is limited to 750 runs/month. Standard is 10,000/month. High-volume workflows (processing hundreds of requests daily) may require premium plans. + +## references + +- https://learn.microsoft.com/en-us/power-automate/getting-started +- https://learn.microsoft.com/en-us/power-automate/get-started-approvals +- https://learn.microsoft.com/en-us/connectors/custom-connectors/ +- https://learn.microsoft.com/en-us/power-automate/triggers-introduction +- https://learn.microsoft.com/en-us/power-automate/use-expressions-in-conditions +- https://learn.microsoft.com/en-us/power-platform/admin/pricing-billing-skus +- https://github.com/microsoft/teams.ts +- https://api.slack.com/workflows — Slack Workflow Builder +- https://api.slack.com/workflows/steps — Slack custom workflow steps + +## instructions + +Use this expert when adding cross-platform support in either direction for workflow automation. It covers: Slack Workflow Builder bridged to Power Automate flows, custom workflow steps bridged to Power Automate custom connectors, approval workflows bridged to the Approvals connector, the Teams Workflows app for simple automations, bot-driven workflow alternatives using state machines + Adaptive Cards, and reverse mapping from Power Automate flows back to Slack Workflow Builder steps and custom workflow_step_execute handlers. Pair with `../teams/ui.adaptive-cards-ts.md` for card construction in bot-driven workflows, `../teams/runtime.proactive-messaging-ts.md` for flow-triggered bot messages, and `slack-interactive-responses-to-teams-ts.md` for card replacement patterns in approval flows. + +## research + +Deep Research prompt: + +"Write a micro expert for bridging Slack Workflow Builder and Microsoft Teams Power Automate / bot-driven orchestration in either direction. Cover: Power Automate as the Teams-side replacement, custom connector creation for Slack custom workflow steps, the built-in Approvals connector for approval flows, the Teams Workflows app for simple automations, bot-driven state machine alternative with Adaptive Card buttons, workflow trigger mapping, variable/dynamic content translation, licensing considerations, and reverse mapping from Power Automate flows back to Slack Workflow Builder steps. Include code examples for bot-driven approvals and a comparison table." diff --git a/plugins/microsoft-365-agents-toolkit/skills/teams-app-developer/experts/builder.md b/plugins/microsoft-365-agents-toolkit/skills/teams-app-developer/experts/builder.md new file mode 100644 index 0000000..3f13933 --- /dev/null +++ b/plugins/microsoft-365-agents-toolkit/skills/teams-app-developer/experts/builder.md @@ -0,0 +1,182 @@ +# builder + +## purpose + +Guided workflow for creating new micro-experts — from scoping and research through drafting, validation, and wiring into the routing system. + +## rules + +1. **One expert, one topic.** Each expert file covers a single, well-bounded topic. If the scope needs an "and" to describe it, split into two experts. +2. **Minimum depth threshold.** Only create a standalone expert if the topic warrants 8+ rules and 2+ code patterns. Below that threshold, add the knowledge to an existing expert instead. +3. **Reusability over specificity.** The expert must apply to future tasks, not just the current one-off request. If the knowledge is project-specific, it belongs in a CLAUDE.md or README, not an expert. +4. **Research before writing.** Never draft rules or patterns from memory alone. Every rule must trace to official docs, SDK source, or verified behavior. If you cannot confirm a claim, mark it `[unverified]`. +5. **Language-agnostic filenames when appropriate.** Use `{topic}-ts.md` when the expert is TypeScript-specific. Use `{topic}.md` (no language suffix) when the expert applies regardless of language (e.g., architecture patterns, workflow guides, platform concepts). +6. **Canonical section order.** Every expert MUST follow the section layout in the expert structure reference below. Omit optional sections entirely rather than leaving them empty. +7. **Rules are imperatives, not observations.** Write "Always call `ack()` before async work" not "ack is important." Each rule must tell the reader exactly what to do or avoid. +8. **Patterns are minimal and self-contained.** Each code snippet demonstrates one concept with all necessary imports. No "see above" references between patterns. +9. **Pitfalls earn their place.** Only include pitfalls that are non-obvious, have bitten real users, or contradict reasonable assumptions. "Don't forget to save the file" is not a pitfall. +10. **No fabricated API signatures.** If a web search yields no confirmation for an API shape, omit it or mark it `[unverified]`. Wrong patterns are worse than missing patterns. +11. **Wire it or it doesn't exist.** An expert that isn't reachable through the routing system (domain `index.md` + root `index.md` signals) will never be loaded. Integration is not optional. +12. **Keep files under 300 lines.** If an expert grows beyond 300 lines, split it into focused sub-experts under the same domain. + +## interview + +### Q1 — Topic & Language +``` +question: "What topic should this expert cover, and is it language-specific?" +header: "Topic" +options: + - label: "TypeScript-specific" + description: "Expert targets TypeScript patterns and APIs. File will be named {topic}-ts.md." + - label: "Language-agnostic" + description: "Expert covers concepts that apply across languages. File will be named {topic}.md." + - label: "You Decide Everything" + description: "Accept recommended defaults for all decisions and skip remaining questions." +multiSelect: false +``` + +### Q2 — Research Depth +``` +question: "How much research should go into this expert before drafting?" +header: "Research" +options: + - label: "Full deep research (Recommended)" + description: "Web search official docs, SDK source, and community guides for each rule and pattern. Thorough but slower." + - label: "Light research" + description: "Quick scan of official docs only. Good when you already have strong domain knowledge." + - label: "Stub only" + description: "Create the file structure with a research prompt but no content yet. Fill in later with the researcher workflow." +multiSelect: false +``` + +### Q3 — Placement +``` +question: "Where should this expert live in the folder structure?" +header: "Placement" +options: + - label: "Existing domain folder" + description: "Place in an existing domain (languages/, tools/, .project/). You'll specify which." + - label: "New domain folder" + description: "Create a new domain folder. Only if 3+ experts will belong to it and it has distinct signal words." + - label: "Root .experts/ folder" + description: "Place at the root level alongside fallback.md. For system-level utilities only." +multiSelect: false +``` + +### defaults table + +| Question | Default | +|---|---| +| Q1 | TypeScript-specific (`{topic}-ts.md`) | +| Q2 | Full deep research | +| Q3 | Existing domain folder | + +## workflow + +### phase 1 — scope + +1. Run the interview above (or use defaults if the developer opted out). +2. Confirm the topic doesn't overlap with an existing expert. Read the target domain's `index.md` file inventory and scan for coverage. +3. If overlap exists, recommend updating the existing expert instead and stop. +4. Decide the filename: `{topic}-ts.md` (language-specific) or `{topic}.md` (language-agnostic). +5. Decide the target folder: existing domain, new domain, or root `.experts/`. + +### phase 2 — research + +1. Write a Deep Research prompt for the topic. Include: SDK/platform name, key concepts, specific APIs to cover, and pattern areas. +2. Execute the prompt as a series of targeted web searches: + - Break into discrete topics (one per API surface, concept, or pattern area). + - Search each individually. Prefer official docs, SDK source, and type definitions. + - For each result, capture: API signatures, parameter types, return types, defaults, and gotchas. +3. If interview answer was "Stub only," write the research prompt into `## research` and skip to phase 5 (integration). The expert will be a stub. +4. If interview answer was "Light research," do a quick scan of official docs only — skip community guides and deep dives. + +### phase 3 — draft + +Write the expert file following the canonical section layout from the expert structure reference below. + +1. **`## purpose`** — One line. What does this expert cover? +2. **`## rules`** — Numbered list of actionable imperatives. Minimum 8 rules for a non-stub expert. Each rule should cite its source (doc link or observed SDK behavior). +3. **`## interview`** (optional) — Include only if the expert requires developer decisions before implementation. Follow the AskUserQuestion format shown in the expert structure reference below. +4. **`## patterns`** — Code snippets showing canonical usage. Each snippet is self-contained with imports. Minimum 2 patterns for a non-stub expert. +5. **`## pitfalls`** — Non-obvious mistakes, breaking changes, version gotchas. +6. **`## references`** — URLs to official docs and SDK source used during research. +7. **`## instructions`** — When to use this expert, what it pairs with (`Pair with: {other-expert}`). +8. **`## research`** — The Deep Research prompt (preserved for future re-research). + +### phase 4 — validate + +Run through this checklist before considering the expert done: + +- [ ] **Minimum depth**: 8+ rules, 2+ patterns (unless intentionally a stub). +- [ ] **Pattern isolation**: Every code snippet compiles in isolation (imports included, no "see above"). +- [ ] **No fabrication**: Every API signature confirmed via research. Unverified claims marked `[unverified]`. +- [ ] **File size**: Under 300 lines. If over, identify split points. +- [ ] **Section completeness**: All required sections present (`purpose`, `rules`, `instructions`, `research`). Optional sections either fully populated or entirely absent. +- [ ] **Rules are imperatives**: Each rule tells the reader what to do/avoid, not what "is" or "exists." +- [ ] **Pitfalls are non-obvious**: No trivial advice. Each pitfall would surprise a competent developer. +- [ ] **Cross-references set**: `## instructions` includes `Pair with:` entries for related experts. + +### phase 5 — integrate + +Wire the new expert into the routing system so it's reachable: + +1. **Domain `index.md`** — Open the target domain's `index.md`: + - Add the file to the appropriate task cluster's `Read:` list (or create a new cluster with a `When:` description). + - Add `Depends on:` / `Cross-domain deps:` if applicable. + - Add the filename to `## file inventory` in alphabetical order. +2. **Root `index.md`** — Open `.experts/index.md`: + - If the new expert introduces signal words not already in the domain's `Signals:` line, add them. + - If this is a new domain, add a full routing entry under `## routing rules`. +3. **Verify routing** — Mentally trace a request that should reach this expert: root router signals → domain router → task cluster → expert file. Confirm the path is unbroken. + +## expert structure reference + +This is the canonical section layout every expert must follow. Required sections are marked; optional sections should be omitted entirely if not needed. + +``` +# {topic}-ts | {topic} ← filename without .md + +## purpose ← REQUIRED. One line. + +## rules ← REQUIRED. Numbered imperatives. + +## interview ← OPTIONAL. Delete if no upfront decisions needed. +### Q1 — {Decision} +(AskUserQuestion format) +### defaults table +(Required if interview exists) + +## patterns ← REQUIRED for non-stubs. Code snippets. + +## pitfalls ← RECOMMENDED. Non-obvious gotchas. + +## references ← RECOMMENDED. Source URLs. + +## instructions ← REQUIRED. When to use, Pair with. + +## research ← REQUIRED. Deep Research prompt. +``` + +## pitfalls + +- **Creating experts for one-off knowledge.** If the topic won't come up again, don't create an expert. Add a note to the relevant domain expert or CLAUDE.md instead. +- **Skipping integration (phase 5).** The most common failure mode. An expert that isn't wired into the routing system is invisible and will never be loaded. +- **Writing rules from memory without research.** Even experienced developers misremember API details. Always verify against current docs — APIs change between SDK versions. +- **Cramming multiple topics into one file.** An expert on "state management and adaptive cards and function calling" should be three experts. The scope test: can you describe it without "and"? +- **Empty optional sections.** An empty `## pitfalls` section signals the author didn't try. Either populate it with real gotchas or omit the section entirely. +- **Forgetting the language suffix decision.** A TypeScript-specific expert named `caching.md` (without `-ts`) will confuse future users about whether it's language-agnostic. Be deliberate about the naming. + +## instructions + +Use this expert when creating any new micro-expert file. + +**Trigger phrases:** "create expert," "new expert," "build expert," "add expert," "make expert," "write expert." + +Pair with: `fallback.md` (if the builder is invoked because fallback detected a knowledge gap that warrants a new expert). + +## research + +Deep Research prompt: + +"Write a meta-expert for creating micro-expert prompt files in a modular AI expert system. Cover: scoping criteria (when to create vs. update), research methodology (web search strategies for SDK docs, source code, type definitions), canonical section layout for expert files, quality validation checklists (minimum rules, pattern isolation, no fabrication), integration steps (domain router wiring, signal word updates), and common failure modes in expert authoring. Include guidance on language-agnostic vs. language-specific naming conventions." diff --git a/plugins/microsoft-365-agents-toolkit/skills/teams-app-developer/experts/convert/bulk-conversion-strategy-ts.md b/plugins/microsoft-365-agents-toolkit/skills/teams-app-developer/experts/convert/bulk-conversion-strategy-ts.md new file mode 100644 index 0000000..f25f305 --- /dev/null +++ b/plugins/microsoft-365-agents-toolkit/skills/teams-app-developer/experts/convert/bulk-conversion-strategy-ts.md @@ -0,0 +1,274 @@ +# bulk-conversion-strategy-ts + +## purpose + +Strategy and workflow for large-scale code conversion — converting 100+ source files (Java POJOs, Ruby classes, JS modules) to TypeScript efficiently with prioritized phases, incremental validation, and tooling. + +## rules + +1. **Never attempt a big-bang conversion.** Convert in phases, ensuring each phase compiles and passes tests before proceeding. A half-converted project that compiles is infinitely better than a fully-converted project that doesn't. +2. **Phase order: Models → Utilities → Core logic → Handlers → Entry point.** Convert bottom-up through the dependency graph. Models have no internal dependencies, so they convert first. Entry points depend on everything, so they convert last. +3. **Prioritize by dependency count.** Run a dependency analysis: files imported by many others convert first (high fan-in). Files that import many others convert last (high fan-out). This minimizes the number of temporary `any` shims. +4. **Use TypeScript's `allowJs: true`** during transition. This lets `.ts` files coexist with unconverted `.js` files. Set `checkJs: false` to avoid type-checking JS files. Remove `allowJs` only when 100% of files are converted. +5. **Create a `@types/source-project` declarations file** for unconverted modules. As you convert models first, other unconverted files may still import them. A `.d.ts` shim keeps the compiler happy during the transition. +6. **Batch similar files.** Group files by pattern (all Lombok `@Data` POJOs, all event handlers, all middleware) and convert each group in one pass. This builds muscle memory and ensures consistency. +7. **Validate each batch immediately.** After converting a batch: (1) `tsc --noEmit` to check types, (2) run relevant tests, (3) commit. Do not accumulate unconverted batches. +8. **Track progress with a conversion manifest.** Maintain a simple JSON or markdown file listing every source file, its status (pending/in-progress/done/skipped), target TS file, and notes. This prevents duplicate work and makes progress visible. +9. **Handle the 80/20 rule.** ~80% of files in a Java project are simple POJOs/models that convert mechanically. ~20% contain complex logic (middleware, async chains, polymorphic factories) that need careful manual conversion. Identify the 20% early and plan extra time. +10. **Establish naming conventions before starting.** Decide once: snake_case API fields stay snake_case or become camelCase? One file per class (Java-style) or group by feature (TS-style)? Barrel exports or direct imports? Document in the conversion manifest. +11. **Write adapter/shim layers for incremental testing.** If the source project has integration tests, create thin adapter layers so converted TS modules can be called from unconverted test harnesses (or vice versa) during transition. +12. **Delete source files after conversion, don't keep both.** Having `User.java` and `User.ts` side by side causes confusion. Once `User.ts` compiles and tests pass, delete `User.java`. The git history preserves the original. + +## interview + +### Q1 — Naming Conventions +``` +question: "How should internal field names be cased in the converted TypeScript code?" +header: "Field casing" +options: + - label: "camelCase (Recommended)" + description: "Convert all internal fields to camelCase (standard TS convention). Wire-format fields (API JSON) keep their original casing via serialization mapping." + - label: "Keep original casing" + description: "Preserve snake_case/PascalCase from the source language as-is. Fewer changes but non-idiomatic TS." + - label: "You Decide Everything" + description: "Accept recommended defaults for all decisions and skip remaining questions." +multiSelect: false +``` + +### Q2 — File Organization +``` +question: "How should converted files be organized?" +header: "File layout" +options: + - label: "Group by feature (Recommended)" + description: "Organize files by feature/module (TS-style). Related types, handlers, and utilities live together." + - label: "One file per class" + description: "Keep the source language's structure (e.g., Java's one-class-per-file). Familiar but can lead to many small files." +multiSelect: false +``` + +### Q3 — Export Style +``` +question: "How should modules be exported?" +header: "Exports" +options: + - label: "Barrel exports (Recommended)" + description: "Each directory gets an index.ts re-exporting its public API. Cleaner imports for consumers." + - label: "Direct imports only" + description: "Import directly from each file path. No barrel files. Simpler but more verbose import paths." +multiSelect: false +``` + +### Q4 — Conversion Scope +``` +question: "Should we convert everything, or focus on specific modules first?" +header: "Scope" +options: + - label: "Full project (phased)" + description: "Convert the entire project in dependency order (models -> utils -> core -> handlers -> entry). Recommended for clean breaks." + - label: "Critical path only" + description: "Convert only the modules needed for the current feature/migration. Remaining modules use .d.ts shims." + - label: "Models + utilities only" + description: "Convert data models and shared utilities. Keep handlers/entry points in source language with interop layer." +multiSelect: false +``` + +### defaults table + +| Question | Default | +|---|---| +| Q1 | camelCase for internal, preserve wire-format | +| Q2 | Group by feature | +| Q3 | Barrel exports | +| Q4 | Full project (phased) | + +## patterns + +### Conversion manifest tracking file + +```markdown +# Conversion Manifest — java-slack-sdk + +## Conventions +- API wire-format fields: keep snake_case +- Internal fields: camelCase +- One interface per model file (may group related types) +- Barrel exports via index.ts per directory + +## Phase 1: Models (200 files) +| Source File | Status | Target File | Notes | +|---|---|---|---| +| model/block/SectionBlock.java | done | src/models/blocks/section-block.ts | | +| model/block/ActionsBlock.java | done | src/models/blocks/actions-block.ts | | +| model/block/DividerBlock.java | done | src/models/blocks/divider-block.ts | | +| model/event/AppMentionEvent.java | in-progress | src/models/events/app-mention-event.ts | Has nested types | +| model/event/MessageEvent.java | pending | src/models/events/message-event.ts | | +| ... | | | | + +## Phase 2: Utilities (15 files) +| Source File | Status | Target File | Notes | +|---|---|---|---| + +## Phase 3: Core Services (30 files) +| Source File | Status | Target File | Notes | +|---|---|---|---| + +## Phase 4: Handlers (40 files) +| Source File | Status | Target File | Notes | +|---|---|---|---| + +## Phase 5: Entry Points (5 files) +| Source File | Status | Target File | Notes | +|---|---|---|---| +``` + +### Dependency graph analysis for prioritization + +```typescript +// Script to analyze Java import graph and determine conversion order +import { readFileSync, readdirSync } from 'fs'; +import { join, relative } from 'path'; + +interface FileNode { + path: string; + imports: string[]; // files this file imports + importedBy: string[]; // files that import this file (fan-in) +} + +function analyzeJavaImports(srcDir: string): FileNode[] { + const files: Map = new Map(); + + // Scan all .java files + function scan(dir: string) { + for (const entry of readdirSync(dir, { withFileTypes: true })) { + const fullPath = join(dir, entry.name); + if (entry.isDirectory()) { + scan(fullPath); + } else if (entry.name.endsWith('.java')) { + const rel = relative(srcDir, fullPath); + const content = readFileSync(fullPath, 'utf-8'); + const imports = [...content.matchAll(/^import\s+([\w.]+);/gm)] + .map((m) => m[1].replace(/\./g, '/') + '.java'); + files.set(rel, { path: rel, imports, importedBy: [] }); + } + } + } + scan(srcDir); + + // Build reverse dependency map (fan-in) + for (const [path, node] of files) { + for (const imp of node.imports) { + const target = files.get(imp); + if (target) { + target.importedBy.push(path); + } + } + } + + // Sort: highest fan-in first (most depended-on → convert first) + return [...files.values()].sort( + (a, b) => b.importedBy.length - a.importedBy.length, + ); +} + +// Usage: +const ordered = analyzeJavaImports('./java-slack-sdk/slack-api-model/src/main/java'); +console.log('Convert in this order:'); +ordered.slice(0, 20).forEach((f) => + console.log(` ${f.path} (imported by ${f.importedBy.length} files)`), +); +``` + +### Batch conversion script for Lombok @Data POJOs + +```typescript +// Semi-automated: reads Java @Data class, outputs TS interface stub +function convertDataClass(javaSource: string): string { + const lines = javaSource.split('\n'); + const className = lines + .find((l) => l.includes('class ')) + ?.match(/class\s+(\w+)/)?.[1] ?? 'Unknown'; + + const fields: { name: string; type: string; serializedName?: string }[] = []; + let serializedName: string | undefined; + + for (const line of lines) { + const snMatch = line.match(/@SerializedName\("(\w+)"\)/); + if (snMatch) { + serializedName = snMatch[1]; + continue; + } + + const fieldMatch = line.match( + /private\s+(?:final\s+)?(\w+(?:<[\w<>,\s]+>)?)\s+(\w+)\s*;/, + ); + if (fieldMatch) { + fields.push({ + type: mapJavaType(fieldMatch[1]), + name: serializedName ?? fieldMatch[2], + serializedName, + }); + serializedName = undefined; + } + } + + const fieldLines = fields + .map((f) => ` ${f.name}: ${f.type};`) + .join('\n'); + + return `export interface ${className} {\n${fieldLines}\n}\n`; +} + +function mapJavaType(javaType: string): string { + const map: Record = { + String: 'string', + boolean: 'boolean', + Boolean: 'boolean', + int: 'number', + Integer: 'number', + long: 'number', + Long: 'number', + double: 'number', + Double: 'number', + float: 'number', + Float: 'number', + }; + if (map[javaType]) return map[javaType]; + if (javaType.startsWith('List<')) { + const inner = javaType.slice(5, -1); + return `${mapJavaType(inner)}[]`; + } + if (javaType.startsWith('Map<')) { + const [k, v] = javaType.slice(4, -1).split(',').map((s) => s.trim()); + return `Record<${mapJavaType(k)}, ${mapJavaType(v)}>`; + } + return javaType; // Keep as-is for custom types (will need manual mapping) +} +``` + +## pitfalls + +- **Converting everything before testing anything**: The biggest risk. Convert 5 model files, compile, test, commit. Then the next 5. Never go more than ~20 files without validating. +- **Ignoring the dependency graph**: Converting a handler before its model types exist forces you to use `any` everywhere, creating tech debt you'll forget to clean up. +- **Inconsistent naming conventions**: If file 1 uses `threadTs` and file 50 uses `thread_ts` for the same field, you'll have runtime bugs. Establish and document conventions in the manifest BEFORE starting. +- **Keeping source and target files**: Having `User.java` and `user.ts` in the repo simultaneously leads to confusion about which is authoritative. Delete the source after confirming the target works. +- **Automating too much**: Semi-automated scripts (like the POJO converter above) produce ~70% correct output. Always review and adjust. Fully automated conversion produces subtle type errors that are harder to find later. +- **Not tracking progress**: After converting 50 of 200 files, it's easy to lose track of what's done. The manifest file is essential for multi-session conversion work. +- **Skipping the hard 20%**: It's tempting to convert all easy POJOs and declare victory. The complex files (middleware, async chains, polymorphic factories) are where the real conversion effort lives. Plan them explicitly. +- **Breaking the build for days**: Use `allowJs: true` to keep the project buildable throughout. If the project must stay deployable during conversion, maintain a working build at all times. + +## references + +- https://www.typescriptlang.org/tsconfig#allowJs -- allowJs for incremental migration +- https://www.typescriptlang.org/docs/handbook/migrating-from-javascript.html -- official migration guide +- https://www.typescriptlang.org/docs/handbook/declaration-files/introduction.html -- .d.ts files for shims + +## instructions + +Use this expert when facing a large-scale conversion (50+ source files). Before converting any code, read this expert to establish the phase order, create a conversion manifest, and run dependency analysis. Pair with the appropriate language expert (`java-to-ts-ts.md`, `ruby-to-ts-ts.md`, or `js-to-ts-ts.md`) for per-file conversion rules, and `json-serialization-ts.md` for serialization-heavy model conversion. + +## research + +Deep Research prompt: + +"Write a micro expert for large-scale language conversion strategy (100+ files from Java/Ruby/JS to TypeScript). Cover: phased conversion order (models → utils → core → handlers → entry), dependency graph analysis for prioritization, conversion manifest tracking, batch processing patterns, allowJs incremental migration, naming convention decisions, validation checkpoints, and common failure modes in big conversions." diff --git a/plugins/microsoft-365-agents-toolkit/skills/teams-app-developer/experts/convert/dependency-mapping-ts.md b/plugins/microsoft-365-agents-toolkit/skills/teams-app-developer/experts/convert/dependency-mapping-ts.md new file mode 100644 index 0000000..18cefce --- /dev/null +++ b/plugins/microsoft-365-agents-toolkit/skills/teams-app-developer/experts/convert/dependency-mapping-ts.md @@ -0,0 +1,117 @@ +# dependency-mapping-ts + +## purpose + +Cross-language dependency mapping — finding npm/TypeScript equivalents for Ruby gems, Java Maven artifacts, and Python pip packages commonly found in Slack bot projects. + +## rules + +1. Always check if an `@types/{package}` exists on DefinitelyTyped before declaring a package as untyped. Run `npm info @types/{package}` or search https://www.npmjs.com/~types. +2. Prefer packages with built-in TypeScript types over untyped packages + `@types` shims. A package exporting its own `.d.ts` is better maintained than relying on community type definitions. +3. When no npm equivalent exists for a gem or Maven artifact, first check if the functionality is built into Node.js (e.g., `crypto`, `http`, `fs`, `url`, `path`, `util`). Many small gems/JARs solve problems that Node.js handles natively. +4. For HTTP clients: Ruby `faraday`/`httparty`/`net/http` and Java `OkHttp`/`HttpClient`/`RestTemplate` all map to `fetch` (built-in since Node 18) or `undici` for advanced use cases. Avoid adding `axios` or `got` unless you need interceptors or retry logic. +5. For web frameworks: Ruby `sinatra` → `express` or `fastify`. Ruby `rails` → `express` + individual packages for ORM, validation, etc. Java `Spring Boot` → `express` or `fastify` + middleware. Do NOT look for a single Rails/Spring equivalent — the Node ecosystem is modular. +6. For testing: Ruby `rspec`/`minitest` → `vitest` or `jest`. Java `JUnit`/`TestNG` → `vitest` or `jest`. Java `Mockito` → `vitest` built-in mocking or `jest.fn()`. +7. For environment/config: Ruby `dotenv` → `dotenv`. Java `System.getenv()` → `process.env`. Java Spring `@Value` / `application.properties` → `dotenv` + typed config module. +8. For JSON handling: Ruby `json` (stdlib) and Java `Jackson`/`Gson` → built-in `JSON.parse()`/`JSON.stringify()`. For schema validation, use `zod` or `ajv`. +9. For database access: Ruby `activerecord`/`sequel` → `prisma`, `drizzle`, or `knex`. Java `Hibernate`/`JPA` → `prisma` or `typeorm`. Java `JDBC` → `pg` (Postgres) / `mysql2` / `better-sqlite3` with raw queries. +10. For scheduling/cron: Ruby `clockwork`/`whenever` → `node-cron` or `bullmq`. Java `ScheduledExecutorService`/`Quartz` → `node-cron` or `bullmq`. +11. For logging: Ruby `logger` (stdlib) → `pino` or `winston`. Java `SLF4J`/`Logback`/`Log4j` → `pino` (fast, JSON) or `winston` (flexible transports). +12. When replacing a dependency, verify feature parity. A mapping table entry doesn't mean the npm package covers 100% of the original's API. Identify which features the bot actually uses and confirm the replacement supports them. + +## patterns + +### Gem → npm mapping table (common Slack bot gems) + +| Ruby Gem | npm Package | Notes | +|---|---|---| +| `slack-ruby-bot` | `@slack/bolt` | Different API; rewrite handlers | +| `slack-ruby-client` | `@slack/web-api` | Direct API client equivalent | +| `sinatra` | `express` | Route syntax differs; see ruby-to-ts-ts.md | +| `faraday` / `httparty` | `fetch` (built-in) | No extra dependency needed (Node 18+) | +| `json` (stdlib) | `JSON` (built-in) | Native in both; zero effort | +| `dotenv` | `dotenv` | Nearly identical API | +| `redis` / `redis-rb` | `ioredis` | TS-typed, Promise-based | +| `pg` | `pg` + `@types/pg` | Same name, same purpose | +| `activerecord` | `prisma` or `drizzle` | Full rewrite of data layer | +| `rspec` | `vitest` | Describe/it syntax similar | +| `puma` / `unicorn` | N/A | Node handles HTTP serving natively | +| `rake` | `tsx` scripts or `npm scripts` | Task runner built into npm | +| `erb` | Template literals or `ejs` | For HTML templating only | +| `chronic` / `ice_cube` | `date-fns` or `luxon` | Date parsing/recurrence | +| `nokogiri` | `cheerio` | HTML/XML parsing | + +### Maven → npm mapping table (common Slack bot JARs) + +| Maven Artifact | npm Package | Notes | +|---|---|---| +| `com.slack.api:bolt` | `@slack/bolt` | Different API; rewrite handlers | +| `com.slack.api:slack-api-client` | `@slack/web-api` | Direct API client equivalent | +| `org.springframework.boot:*` | `express` + middleware | No single equivalent; modular | +| `com.google.code.gson:gson` | `JSON` (built-in) | Native JSON support | +| `com.fasterxml.jackson.core:*` | `JSON` (built-in) + `zod` | Zod for schema validation | +| `org.apache.httpcomponents:httpclient` | `fetch` (built-in) | No extra dependency (Node 18+) | +| `org.slf4j:slf4j-api` | `pino` or `winston` | Structured logging | +| `ch.qos.logback:logback-classic` | `pino` | Fast JSON logger | +| `org.junit.jupiter:*` | `vitest` | Test framework | +| `org.mockito:*` | `vitest` mocking | Built-in mock support | +| `io.github.cdimascio:dotenv-java` | `dotenv` | Same concept | +| `com.zaxxer:HikariCP` | N/A | Node uses single-thread; pool via `pg` | +| `org.postgresql:postgresql` | `pg` + `@types/pg` | PostgreSQL driver | +| `redis.clients:jedis` | `ioredis` | Redis client | +| `com.google.guava:guava` | Various / built-in | Most Guava utils are native in JS | + +### Dependency audit workflow + +```typescript +// Step 1: Extract all dependencies from the source project +// Ruby: parse Gemfile / Gemfile.lock +// Java: parse pom.xml / build.gradle + +// Step 2: Categorize each dependency +type DepCategory = + | 'builtin' // Covered by Node.js or TS natively + | 'direct-map' // 1:1 npm equivalent exists + | 'rewrite' // Functionality exists but API differs significantly + | 'eliminate' // Language-specific concern (e.g., thread pools, GC tuning) + | 'custom'; // No equivalent; must implement from scratch + +interface DependencyAudit { + source: string; // e.g., "faraday" or "com.google.code.gson:gson" + category: DepCategory; + target: string; // npm package name or "built-in" + notes: string; // Migration notes + typesPackage?: string; // @types/* if needed +} + +// Step 3: Install and verify each mapped dependency +// npm install {package} @types/{package} +// Step 4: Write adapter code for 'rewrite' category deps +``` + +## pitfalls + +- **Don't assume name similarity means API similarity**: Ruby's `redis` gem and npm's `ioredis` serve the same purpose but have completely different APIs. Plan for handler rewrites. +- **Check Node.js built-ins first**: Before adding `uuid`, check if `crypto.randomUUID()` suffices. Before adding `axios`, check if `fetch` works. Before adding `path-to-regexp`, check if your framework already includes routing. +- **Gem/JAR version matters**: A Ruby project on `slack-ruby-bot 0.10` has a very different API from `0.16`. Check the source project's locked version to understand which features are actually used. +- **Transitive dependencies**: Ruby's `Gemfile.lock` and Java's dependency tree include transitive deps. Only map the **direct** dependencies — transitives are handled by the npm package you're switching to. +- **Dev dependencies**: Don't forget to map dev-only tools: `rubocop` → `eslint` + `prettier`, `bundler` → `npm`, `mvn` → `npm scripts`, `pry` → Node debugger. +- **Web server is implicit**: Ruby needs `puma`/`unicorn`/`thin` as a web server. Java needs `tomcat`/`jetty` (embedded in Spring). Node.js `http` module IS the web server — no additional package needed (Express wraps it). + +## references + +- https://www.npmjs.com/ -- npm package registry (search for equivalents) +- https://www.npmjs.com/~types -- DefinitelyTyped @types packages +- https://rubygems.org/ -- Ruby gem registry (for understanding source deps) +- https://search.maven.org/ -- Maven Central (for understanding source deps) +- https://nodejs.org/api/ -- Node.js built-in modules + +## instructions + +Use this expert when auditing and replacing dependencies during a language conversion. Start by extracting all dependencies from the source project (Gemfile, pom.xml, build.gradle, or package.json), then categorize each using the audit workflow pattern. Consult the mapping tables for common equivalents. Pair with the appropriate language conversion expert (`js-to-ts-ts.md`, `ruby-to-ts-ts.md`, or `java-to-ts-ts.md`) for API-level migration guidance. + +## research + +Deep Research prompt: + +"Write a micro expert for mapping dependencies across languages to TypeScript/npm. Cover: Ruby gems to npm packages, Java Maven artifacts to npm packages, identifying Node.js built-in replacements, @types packages from DefinitelyTyped, dependency audit workflow, and common Slack bot dependency mappings. Include mapping tables for the 15 most common gems and 15 most common Maven artifacts found in chat bot projects." diff --git a/plugins/microsoft-365-agents-toolkit/skills/teams-app-developer/experts/convert/index.md b/plugins/microsoft-365-agents-toolkit/skills/teams-app-developer/experts/convert/index.md new file mode 100644 index 0000000..04d34cc --- /dev/null +++ b/plugins/microsoft-365-agents-toolkit/skills/teams-app-developer/experts/convert/index.md @@ -0,0 +1,79 @@ +# convert-router + +## purpose + +Route language-conversion tasks to the minimal set of micro-expert files. Each expert covers rewriting source code from one language into idiomatic TypeScript. + +## task clusters + +### JS → TypeScript +When: converting JavaScript files to TypeScript, adding types, modernizing imports, enabling strict mode +Read: +- `js-to-ts-ts.md` +Depends on: `type-mapping-ts.md` (type system reference) + +### Ruby → TypeScript +When: rewriting Ruby code in TypeScript, translating Ruby idioms, converting gems to npm +Read: +- `ruby-to-ts-ts.md` +- `dependency-mapping-ts.md` +Depends on: `type-mapping-ts.md` (type system reference) + +### Java → TypeScript +When: rewriting Java code in TypeScript, translating Java OOP patterns, Lombok annotations, CompletableFuture async, converting Maven/Gradle deps to npm +Read: +- `java-to-ts-ts.md` +- `json-serialization-ts.md` +- `dependency-mapping-ts.md` +Depends on: `type-mapping-ts.md` (type system reference) + +### Kotlin → TypeScript +When: rewriting Kotlin code in TypeScript, trailing lambdas, SAM conversions, `it` implicit parameter, string templates, `trimIndent()`, null-safety operators (`?.`, `!!`, `?:`), `when` expressions, extension functions, data classes, companion objects, sealed classes, `::class.java` references +Read: +- `kotlin-to-ts-ts.md` +- `java-to-ts-ts.md` (Kotlin uses Java SDK types) +- `dependency-mapping-ts.md` +Depends on: `type-mapping-ts.md` (type system reference) + +### JSON serialization conversion +When: converting Gson/Jackson serialization to TypeScript JSON + Zod, polymorphic deserialization, @SerializedName mapping +Read: +- `json-serialization-ts.md` +Depends on: `type-mapping-ts.md` (type system reference) + +### Bulk/large-scale conversion +When: converting 50+ source files, planning phased conversion, tracking progress across many files +Read: +- `bulk-conversion-strategy-ts.md` +Depends on: The appropriate language-specific expert + +### Cross-language dependency mapping +When: finding npm equivalents for gems, Maven artifacts, or pip packages +Read: +- `dependency-mapping-ts.md` + +### Cross-language type mapping +When: translating type systems between languages, mapping nullable/generic/enum patterns to TypeScript +Read: +- `type-mapping-ts.md` + +### Composite: Full language conversion +When: complete end-to-end source rewrite from any supported language to TypeScript +Read: +- The appropriate language-specific expert (`js-to-ts-ts.md`, `ruby-to-ts-ts.md`, `java-to-ts-ts.md`, or `kotlin-to-ts-ts.md`) +- `json-serialization-ts.md` (if Java source with Gson/Jackson) +- `bulk-conversion-strategy-ts.md` (if 50+ source files) +- `dependency-mapping-ts.md` +- `type-mapping-ts.md` +Cross-domain deps: If also bridging platforms, pair with `../bridge/index.md` for Slack↔Teams or AWS↔Azure concerns. + +## combining rule + +If a request involves **language conversion** and **platform bridging**, read the language-specific expert here first (to rewrite the source), then route through `../bridge/index.md` for platform-specific mapping. + +## file inventory + +`bulk-conversion-strategy-ts.md` | `dependency-mapping-ts.md` | `java-to-ts-ts.md` | `js-to-ts-ts.md` | `json-serialization-ts.md` | `kotlin-to-ts-ts.md` | `ruby-to-ts-ts.md` | `type-mapping-ts.md` + + + diff --git a/plugins/microsoft-365-agents-toolkit/skills/teams-app-developer/experts/convert/java-to-ts-ts.md b/plugins/microsoft-365-agents-toolkit/skills/teams-app-developer/experts/convert/java-to-ts-ts.md new file mode 100644 index 0000000..ed7f188 --- /dev/null +++ b/plugins/microsoft-365-agents-toolkit/skills/teams-app-developer/experts/convert/java-to-ts-ts.md @@ -0,0 +1,448 @@ +# java-to-ts-ts + +## purpose + +Rewriting Java source code as idiomatic TypeScript — mapping Java's class-based OOP, generics, annotations, collections, and concurrency patterns to their TypeScript equivalents. + +## rules + +1. Java class hierarchies map to TypeScript interfaces + classes. Prefer interfaces over abstract classes for defining contracts. Java `implements Interface` maps directly to TypeScript `implements Interface`. Java `extends AbstractClass` maps to TypeScript `extends BaseClass`. +2. Java generics map to TypeScript generics with the same `` syntax. Key difference: Java generics are erased at runtime; TypeScript generics are erased at compile time. Both are structural at their core. Java bounded wildcards (`? extends T`) map to TS constrained generics (``). Java `? super T` has no direct TS equivalent — use a union or contravariant generic. +3. Java annotations (`@Override`, `@Deprecated`, `@JsonProperty`) have no built-in TS equivalent. Map to: TS decorators (experimental, stage 3), JSDoc comments, or runtime metadata patterns. For simple markers like `@Override`, simply remove them — TS enforces override correctness with the `override` keyword. +4. Java `Optional` maps to `T | null` or `T | undefined`. `Optional.of(x)` → just `x`, `Optional.empty()` → `null`, `Optional.isPresent()` → `!= null`, `Optional.map(fn)` → optional chaining + nullish coalescing (`x?.transform() ?? default`). +5. Java checked exceptions do not exist in TypeScript. Remove `throws` declarations from method signatures. Convert `try/catch` blocks but let unexpected errors propagate naturally. Document thrown errors in JSDoc if important for callers. +6. Java `final` maps to `readonly` for class fields and `const` for local variables. Java `final` on method parameters has no TS equivalent (parameters are already effectively final by convention). +7. Java `static` methods and fields map directly to TypeScript `static`. Java static utility classes (e.g., `Collections`, `Math`) often map better to standalone exported functions rather than a class with all-static members. +8. Java `enum` maps to TypeScript `enum` for simple cases, but prefer string literal unions for most use cases. Java enums with methods and fields → TypeScript `as const` object + associated functions or a class hierarchy. +9. Java `Stream` API maps to TypeScript array methods. `stream().filter().map().collect(Collectors.toList())` becomes `.filter().map()`. `Collectors.toMap()` → `reduce()` or `Object.fromEntries()`. `Collectors.groupingBy()` → `Object.groupBy()` or `reduce()`. +10. Java `synchronized` / `volatile` / `Lock` have no TypeScript equivalent (JS is single-threaded). Remove synchronization primitives entirely. If the Java code uses threads for parallelism, redesign around `Promise.all()`, async/await, or worker threads. +11. Java `Map` maps to `Map` (JS built-in) or `Record` for string-keyed maps. `List` → `T[]` or `Array`. `Set` → `Set`. Java `HashMap`/`TreeMap` distinctions are irrelevant — JS `Map` has insertion-order iteration. +12. Java getter/setter pairs (`getName()`/`setName()`) should be simplified to direct property access in TS. Only use `get`/`set` accessors if validation or side effects are needed. +13. Java `StringBuilder` / string concatenation in loops → template literals or `Array.join()`. TS strings are immutable like Java strings, but template literals handle most interpolation needs. +14. Java package structure (`com.example.app.service`) does NOT map to deeply nested TS folders. Flatten to a pragmatic folder structure: `src/services/`, `src/models/`, etc. Use barrel files (`index.ts`) for clean re-exports. +15. **Lombok `@Data`** generates getters, setters, `equals()`, `hashCode()`, `toString()`, and a required-args constructor. In TypeScript, replace with a plain `interface` (for data-only types) or a `class` with `public` constructor parameters. Remove all generated method equivalents — TS doesn't need them. +16. **Lombok `@Builder`** generates a fluent builder class. Replace with a TypeScript options interface: `new Foo({ bar, baz })` or a factory function. The builder pattern is unnecessary when constructors accept named parameters via object destructuring. +17. **Lombok `@Getter`/`@Setter`** on individual fields → direct `public` property access in TS. If the field was `@Getter` only (read-only), use `readonly`. If `@Setter` has custom logic, use a TS `set` accessor. +18. **Lombok `@AllArgsConstructor`/`@NoArgsConstructor`/`@RequiredArgsConstructor`** → TypeScript constructor with explicit parameters. `@NoArgsConstructor` on a data class → all properties optional or have defaults. `@RequiredArgsConstructor` → constructor with only `final` fields as parameters. +19. **Lombok `@Slf4j`** generates a `private static final Logger log` field. Replace with a module-level logger: `import pino from 'pino'; const log = pino({ name: 'MyClass' });` or accept a logger via constructor injection. +20. **Lombok `@Value`** (immutable `@Data`) → TypeScript `interface` with all `readonly` fields, or use `Readonly` utility type. +21. **`CompletableFuture`** maps to `Promise`. `thenApply(fn)` → `.then(fn)`, `thenCompose(fn)` → `.then(fn)` (Promise auto-flattens), `exceptionally(fn)` → `.catch(fn)`, `thenAccept(fn)` → `.then(fn)` (when return is void). +22. **`CompletableFuture.allOf()`** → `Promise.all()`. `CompletableFuture.anyOf()` → `Promise.race()`. `CompletableFuture.supplyAsync(fn, executor)` → just call the async function directly (no executor needed in single-threaded JS). +23. **`CompletableFuture` chains** should be rewritten as `async/await` for readability. A chain of `.thenApply().thenCompose().exceptionally()` becomes a simple `try { const a = await step1(); const b = await step2(a); } catch (e) { ... }`. +24. **`@FunctionalInterface`** annotations → TypeScript function type aliases. `@FunctionalInterface interface Handler { void handle(T t); }` becomes `type Handler = (t: T) => void`. + +## patterns + +### Java class hierarchy → TypeScript interfaces + classes + +```java +// --- Before (Java) --- +public interface MessageHandler { + void handle(Message message); + boolean canHandle(String type); +} + +public abstract class BaseHandler implements MessageHandler { + protected final Logger logger; + + public BaseHandler(Logger logger) { + this.logger = logger; + } + + @Override + public boolean canHandle(String type) { + return getSupportedTypes().contains(type); + } + + protected abstract Set getSupportedTypes(); +} + +public class SlashCommandHandler extends BaseHandler { + private final CommandRegistry registry; + + public SlashCommandHandler(Logger logger, CommandRegistry registry) { + super(logger); + this.registry = registry; + } + + @Override + public void handle(Message message) { + String command = message.getText().split(" ")[0]; + registry.execute(command, message); + } + + @Override + protected Set getSupportedTypes() { + return Set.of("slash_command", "block_actions"); + } +} +``` + +```typescript +// --- After (TypeScript) --- +interface MessageHandler { + handle(message: Message): void; + canHandle(type: string): boolean; +} + +abstract class BaseHandler implements MessageHandler { + constructor(protected readonly logger: Logger) {} + + canHandle(type: string): boolean { + return this.getSupportedTypes().has(type); + } + + abstract handle(message: Message): void; + protected abstract getSupportedTypes(): Set; +} + +class SlashCommandHandler extends BaseHandler { + constructor( + logger: Logger, + private readonly registry: CommandRegistry, + ) { + super(logger); + } + + handle(message: Message): void { + const command = message.text.split(' ')[0]; + this.registry.execute(command, message); + } + + protected getSupportedTypes(): Set { + return new Set(['slash_command', 'block_actions']); + } +} +``` + +### Java Stream API → TypeScript array methods + +```java +// --- Before (Java) --- +import java.util.stream.Collectors; + +List activeUsers = users.stream() + .filter(u -> u.isActive()) + .filter(u -> !u.getRole().equals(Role.GUEST)) + .sorted(Comparator.comparing(User::getName)) + .map(u -> new UserDTO(u.getName(), u.getEmail())) + .collect(Collectors.toList()); + +Map> byDepartment = users.stream() + .collect(Collectors.groupingBy(User::getDepartment)); + +Optional admin = users.stream() + .filter(u -> u.getRole().equals(Role.ADMIN)) + .findFirst(); +``` + +```typescript +// --- After (TypeScript) --- +interface UserDTO { + name: string; + email: string; +} + +const activeUsers: UserDTO[] = users + .filter((u) => u.active) + .filter((u) => u.role !== 'guest') + .sort((a, b) => a.name.localeCompare(b.name)) + .map((u) => ({ name: u.name, email: u.email })); + +const byDepartment: Record = Object.groupBy( + users, + (u) => u.department, +) as Record; + +const admin: User | undefined = users.find((u) => u.role === 'admin'); +``` + +### Java enum with behavior → TypeScript const object + functions + +```java +// --- Before (Java) --- +public enum Priority { + HIGH(1, "High Priority"), + MEDIUM(2, "Medium Priority"), + LOW(3, "Low Priority"); + + private final int level; + private final String label; + + Priority(int level, String label) { + this.level = level; + this.label = label; + } + + public int getLevel() { return level; } + public String getLabel() { return label; } + + public boolean isUrgent() { + return this == HIGH; + } +} +``` + +```typescript +// --- After (TypeScript) --- +const Priority = { + HIGH: { level: 1, label: 'High Priority' }, + MEDIUM: { level: 2, label: 'Medium Priority' }, + LOW: { level: 3, label: 'Low Priority' }, +} as const; + +type PriorityKey = keyof typeof Priority; +type PriorityValue = (typeof Priority)[PriorityKey]; + +function isUrgent(priority: PriorityValue): boolean { + return priority === Priority.HIGH; +} +``` + +### Lombok @Data/@Builder → TypeScript interface + options constructor + +```java +// --- Before (Java with Lombok) --- +import lombok.Builder; +import lombok.Data; +import lombok.extern.slf4j.Slf4j; + +@Data +@Builder +@Slf4j +public class SlackMessage { + private final String channel; + private final String text; + private final String threadTs; + private final boolean unfurlLinks; + private final List attachments; + + public void send(WebClient client) { + log.info("Sending message to {}", channel); + client.chatPostMessage(r -> r + .channel(channel) + .text(text) + .threadTs(threadTs) + .unfurlLinks(unfurlLinks) + .attachments(attachments)); + } +} + +// Usage with builder: +SlackMessage msg = SlackMessage.builder() + .channel("#general") + .text("Hello!") + .unfurlLinks(false) + .build(); +msg.send(client); +``` + +```typescript +// --- After (TypeScript) --- +import pino from 'pino'; + +const log = pino({ name: 'SlackMessage' }); + +interface SlackMessageOptions { + channel: string; + text: string; + threadTs?: string; + unfurlLinks?: boolean; + attachments?: Attachment[]; +} + +// Interface replaces @Data — no getters/setters/equals/hashCode/toString needed +// Options object replaces @Builder — named params via destructuring +class SlackMessage { + readonly channel: string; + readonly text: string; + readonly threadTs?: string; + readonly unfurlLinks: boolean; + readonly attachments: Attachment[]; + + constructor({ + channel, + text, + threadTs, + unfurlLinks = false, + attachments = [], + }: SlackMessageOptions) { + this.channel = channel; + this.text = text; + this.threadTs = threadTs; + this.unfurlLinks = unfurlLinks; + this.attachments = attachments; + } + + send(client: WebClient): void { + log.info(`Sending message to ${this.channel}`); + client.chat.postMessage({ + channel: this.channel, + text: this.text, + thread_ts: this.threadTs, + unfurl_links: this.unfurlLinks, + attachments: this.attachments, + }); + } +} + +// Usage — options object replaces builder chain: +const msg = new SlackMessage({ + channel: '#general', + text: 'Hello!', + unfurlLinks: false, +}); +msg.send(client); +``` + +### CompletableFuture chain → async/await + +```java +// --- Before (Java) --- +import java.util.concurrent.CompletableFuture; + +public class AsyncSlackClient { + private final MethodsClient client; + private final ExecutorService executor; + + public CompletableFuture fetchAndNotify(String userId, String channel) { + return CompletableFuture.supplyAsync(() -> client.usersInfo(r -> r.user(userId)), executor) + .thenApply(response -> response.getUser().getRealName()) + .thenCompose(name -> CompletableFuture.supplyAsync( + () -> client.chatPostMessage(r -> r.channel(channel).text("Hello " + name)), + executor + )) + .thenApply(response -> response.getTs()) + .exceptionally(ex -> { + log.error("Failed: {}", ex.getMessage()); + return null; + }); + } + + public CompletableFuture> fetchMultipleUsers(List userIds) { + List> futures = userIds.stream() + .map(id -> CompletableFuture.supplyAsync( + () -> client.usersInfo(r -> r.user(id)).getUser().getRealName(), + executor + )) + .collect(Collectors.toList()); + + return CompletableFuture.allOf(futures.toArray(new CompletableFuture[0])) + .thenApply(v -> futures.stream() + .map(CompletableFuture::join) + .collect(Collectors.toList())); + } +} +``` + +```typescript +// --- After (TypeScript) --- +class AsyncSlackClient { + constructor(private readonly client: WebClient) {} + + // CompletableFuture chain → simple async/await + async fetchAndNotify(userId: string, channel: string): Promise { + try { + const userResponse = await this.client.users.info({ user: userId }); + const name = userResponse.user?.real_name ?? 'Unknown'; + const msgResponse = await this.client.chat.postMessage({ + channel, + text: `Hello ${name}`, + }); + return msgResponse.ts ?? null; + } catch (err) { + log.error(`Failed: ${(err as Error).message}`); + return null; + } + } + + // CompletableFuture.allOf → Promise.all + async fetchMultipleUsers(userIds: string[]): Promise { + const results = await Promise.all( + userIds.map(async (id) => { + const response = await this.client.users.info({ user: id }); + return response.user?.real_name ?? 'Unknown'; + }), + ); + return results; + } +} +``` + +### @FunctionalInterface → TypeScript function types + +```java +// --- Before (Java) --- +@FunctionalInterface +public interface BoltEventHandler { + Response apply(EventsApiPayload payload, EventContext context) throws Exception; +} + +@FunctionalInterface +public interface Middleware { + Response apply(Request req, Response resp, MiddlewareChain chain) throws Exception; +} + +// Usage: +app.event(AppMentionEvent.class, (payload, ctx) -> { + ctx.say("Hello!"); + return ctx.ack(); +}); +``` + +```typescript +// --- After (TypeScript) --- +// @FunctionalInterface → type alias for the function signature +type BoltEventHandler = ( + payload: EventsApiPayload, + context: EventContext, +) => Promise; + +type Middleware = ( + req: Request, + resp: Response, + chain: MiddlewareChain, +) => Promise; + +// Usage — identical lambda syntax: +app.event(AppMentionEvent, async (payload, ctx) => { + await ctx.say('Hello!'); + return ctx.ack(); +}); +``` + +## pitfalls + +- **Null vs undefined**: Java has one null; TypeScript has `null` AND `undefined`. Decide on a convention early. Recommendation: use `undefined` for "not provided" (optional params), `null` for "explicitly empty" (API responses). +- **No method overloading at runtime**: Java allows multiple methods with the same name but different signatures. TypeScript supports overload signatures but only one implementation. Merge overloads into a single function with union parameter types. +- **Access modifiers are compile-time only**: TypeScript's `private`/`protected` are erased at runtime (unlike Java). For true runtime privacy, use `#privateField` (ES2022 private fields). +- **No runtime type checking**: Java's `instanceof` checks actual class identity. TypeScript's `instanceof` works for classes but NOT for interfaces (they're erased). Use discriminated unions or type guard functions instead. +- **Collections are not auto-imported**: Java's `List`, `Map`, `Set` are imports from `java.util`. TypeScript's `Array`, `Map`, `Set` are global built-ins — no import needed. But helper methods like `Object.groupBy()` may need a polyfill. +- **Checked exceptions disappear**: Java forces callers to handle checked exceptions. TypeScript has no mechanism for this. Document important error conditions in JSDoc comments. +- **Java `equals()` vs TS `===`**: Java objects use `.equals()` for value comparison. TS `===` compares references for objects. Use deep-equal libraries or compare relevant fields explicitly. +- **Thread safety patterns are dead code**: Remove all `synchronized`, `volatile`, `Lock`, `Atomic*` patterns. JS is single-threaded. Keeping them adds confusion with zero benefit. +- **Builder pattern is often unnecessary**: Java builders exist because constructors can't have named parameters. TypeScript objects with optional properties serve the same purpose more concisely. +- **Over-engineering inheritance**: Java projects often have deep class hierarchies. In TypeScript, prefer composition and interfaces. Flatten hierarchies where possible — if a class exists only to share one method, use a utility function instead. +- **Lombok `@Data` on mutable classes**: If the Java class was mutable (setters used), decide whether TS version should be mutable too. Often the answer is no — make properties `readonly` and create new instances instead of mutating. +- **Lombok `@Builder.Default`**: Default values in Lombok builders (`@Builder.Default private boolean unfurlLinks = true`) must become explicit defaults in the TS constructor destructuring: `{ unfurlLinks = true }: Options`. +- **`CompletableFuture.join()` blocks the thread**: There is NO blocking equivalent in JS. `await` is non-blocking. Code that uses `join()` for synchronous access must be redesigned to be fully async. +- **`ExecutorService` thread pools**: Remove entirely. JS is single-threaded. `Promise.all()` provides concurrency for I/O-bound work without thread management. For CPU-bound work, use worker threads only if profiling shows a bottleneck. +- **`@FunctionalInterface` with checked exceptions**: Java functional interfaces can declare `throws Exception`. TypeScript function types cannot. Async functions that reject should document their error types in JSDoc but cannot enforce catching at the type level. + +## references + +- https://www.typescriptlang.org/docs/handbook/2/classes.html -- TS classes and inheritance +- https://www.typescriptlang.org/docs/handbook/2/generics.html -- TS generics +- https://www.typescriptlang.org/docs/handbook/decorators.html -- TS decorators (annotation equivalent) +- https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Map -- JS Map (HashMap equivalent) +- https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Promise -- Promise (Future equivalent) + +## instructions + +Use this expert when rewriting Java source code in TypeScript. Start by identifying the Java patterns in use (class hierarchies, generics, annotations, Lombok annotations, Stream API, CompletableFuture chains, functional interfaces, concurrency, Optional) and map each to its TS equivalent. Focus on simplification: flatten unnecessary class hierarchies, replace Lombok @Data/@Builder with interfaces and options objects, remove builder patterns in favor of typed options objects, rewrite CompletableFuture chains as async/await, convert @FunctionalInterface to type aliases, eliminate synchronization code, and convert getters/setters to direct property access. Pair with `dependency-mapping-ts.md` for Maven/Gradle → npm equivalents, `type-mapping-ts.md` for cross-language type reference, and `json-serialization-ts.md` for Gson/Jackson serialization conversion. + +## research + +Deep Research prompt: + +"Write a micro expert on converting Java to TypeScript. Cover: class hierarchies to interfaces/classes, generics mapping, annotations to decorators, Stream API to array methods, Optional to nullable types, checked exceptions removal, synchronized/volatile removal, enum with behavior to const objects, getter/setter simplification, builder pattern elimination, and package structure flattening. Include 3 worked examples." diff --git a/plugins/microsoft-365-agents-toolkit/skills/teams-app-developer/experts/convert/js-to-ts-ts.md b/plugins/microsoft-365-agents-toolkit/skills/teams-app-developer/experts/convert/js-to-ts-ts.md new file mode 100644 index 0000000..3ac40a4 --- /dev/null +++ b/plugins/microsoft-365-agents-toolkit/skills/teams-app-developer/experts/convert/js-to-ts-ts.md @@ -0,0 +1,131 @@ +# js-to-ts-ts + +## purpose + +Converting JavaScript source files to idiomatic TypeScript — adding type annotations, modernizing module syntax, configuring strict compilation, and handling untyped dependencies. + +## rules + +1. Rename `.js` files to `.ts` (or `.tsx` for JSX). This is the first mechanical step — TypeScript compiles `.ts` files and ignores `.js` by default unless `allowJs` is set. +2. Convert `require()`/`module.exports` to ESM `import`/`export`. `const x = require('y')` becomes `import x from 'y'` (default) or `import { x } from 'y'` (named). `module.exports = { a, b }` becomes `export { a, b }`. +3. Enable `"strict": true` in `tsconfig.json` from the start. Fixing strict errors during conversion is far easier than enabling strict later and facing hundreds of errors at once. +4. Prefer `interface` over `type` for object shapes — interfaces are extensible and produce better error messages. Use `type` for unions, intersections, and mapped types. +5. Replace `/** @type {X} */` JSDoc annotations with inline TypeScript annotations. JSDoc types are redundant once the file is `.ts`. +6. Add explicit return types to exported functions. Internal/private functions can rely on inference, but public API boundaries should have declared types for documentation and refactor safety. +7. Replace `any` with specific types. When the real type is unknown, prefer `unknown` and narrow with type guards. Use `any` only as a temporary escape hatch, marked with `// TODO: type this`. +8. For untyped npm dependencies, install `@types/{package}` from DefinitelyTyped. If no `@types` package exists, create a minimal `declarations.d.ts` with `declare module '{package}'`. +9. Convert dynamic property access patterns (`obj[key]`) to use `Record` or an index signature. Slack bots frequently use `payload[field]` patterns that need explicit typing. +10. Replace `arguments` object usage with rest parameters (`...args: T[]`). Replace `Function.prototype.apply/call` patterns with direct invocation or spread syntax. +11. Convert `var` to `const`/`let`. Prefer `const` unless reassignment is needed. +12. Add `as const` assertions to literal objects and arrays that should not be widened (e.g., configuration objects, route tables). + +## patterns + +### require/module.exports → ESM import/export + +```javascript +// --- Before (JS) --- +const express = require('express'); +const { WebClient } = require('@slack/web-api'); +const config = require('./config'); + +function createApp(port) { + const app = express(); + app.listen(port); + return app; +} + +module.exports = { createApp }; +``` + +```typescript +// --- After (TS) --- +import express from 'express'; +import { WebClient } from '@slack/web-api'; +import config from './config.js'; + +function createApp(port: number): express.Application { + const app = express(); + app.listen(port); + return app; +} + +export { createApp }; +``` + +### Typing callback-heavy patterns + +```javascript +// --- Before (JS) --- +function fetchData(url, callback) { + fetch(url) + .then(res => res.json()) + .then(data => callback(null, data)) + .catch(err => callback(err, null)); +} +``` + +```typescript +// --- After (TS) --- +interface FetchResult { + data: T; + status: number; +} + +async function fetchData(url: string): Promise> { + const res = await fetch(url); + const data: T = await res.json(); + return { data, status: res.status }; +} +``` + +### Starter tsconfig.json for conversion projects + +```json +{ + "compilerOptions": { + "target": "ES2022", + "module": "Node16", + "moduleResolution": "Node16", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "outDir": "./dist", + "rootDir": "./src", + "declaration": true, + "sourceMap": true, + "resolveJsonModule": true + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "dist"] +} +``` + +## pitfalls + +- **`esModuleInterop` required for CJS default imports**: Without it, `import express from 'express'` fails for CommonJS packages. Always enable `esModuleInterop: true`. +- **JSON imports need `resolveJsonModule`**: JS code that does `require('./data.json')` won't work in TS without `resolveJsonModule: true` in tsconfig. +- **Implicit `any` in callbacks**: Event handler callbacks like `app.on('data', (msg) => ...)` often infer `any` for parameters. Add explicit types: `(msg: IncomingMessage) => ...`. +- **`this` context in class methods**: JS classes using `this` in callbacks lose context. Use arrow functions or add explicit `this` parameter types. +- **Optional chaining vs truthy checks**: JS code like `if (obj && obj.prop)` can become `obj?.prop` in TS, but be careful with falsy values (`0`, `""`, `false`) — optional chaining only checks `null`/`undefined`. +- **Enum vs union**: Don't reflexively convert string constants to `enum`. Prefer string literal unions (`type Status = 'active' | 'inactive'`) unless you need reverse mapping. +- **Missing `@types` packages**: Not all npm packages have types. Check with `npm info @types/{package}` before creating manual declarations. +- **`export default` vs `export =`**: Some CJS modules use `export = X` in their type definitions. Import these with `import X from 'module'` (with `esModuleInterop`) not `import { X }`. + +## references + +- https://www.typescriptlang.org/docs/handbook/migrating-from-javascript.html +- https://www.typescriptlang.org/tsconfig -- tsconfig reference +- https://github.com/DefinitelyTyped/DefinitelyTyped -- @types packages +- https://www.typescriptlang.org/docs/handbook/2/types-from-types.html -- utility types + +## instructions + +Use this expert when converting JavaScript source files to TypeScript. Start by renaming files and converting module syntax, then progressively add types starting from the public API surface inward. Pair with `type-mapping-ts.md` for cross-language type reference and `dependency-mapping-ts.md` if the JS project uses packages that need TS-typed alternatives. + +## research + +Deep Research prompt: + +"Write a micro expert on converting JavaScript to TypeScript. Cover: require/module.exports to ESM imports, tsconfig strict mode setup, typing callback patterns, handling untyped dependencies with @types and declaration files, common JS idioms that need TS adaptation (var, arguments, dynamic property access), and a starter tsconfig.json for conversion projects." diff --git a/plugins/microsoft-365-agents-toolkit/skills/teams-app-developer/experts/convert/json-serialization-ts.md b/plugins/microsoft-365-agents-toolkit/skills/teams-app-developer/experts/convert/json-serialization-ts.md new file mode 100644 index 0000000..5e2b18c --- /dev/null +++ b/plugins/microsoft-365-agents-toolkit/skills/teams-app-developer/experts/convert/json-serialization-ts.md @@ -0,0 +1,223 @@ +# json-serialization-ts + +## purpose + +Converting Java Gson/Jackson JSON serialization patterns to TypeScript — replacing custom serializers, `@SerializedName` annotations, polymorphic type factories, and schema validation with native `JSON.parse()`/`JSON.stringify()`, Zod schemas, and discriminated unions. + +## rules + +1. Java's Gson/Jackson are replaced by the built-in `JSON.parse()` and `JSON.stringify()`. No library needed for basic serialization. Add `zod` only when you need runtime schema validation (external API responses, user input). +2. Gson `@SerializedName("snake_case")` on Java fields maps to a Zod schema with `.transform()` for renaming, or simply use the snake_case keys directly in the TypeScript interface if the JSON wire format is snake_case (common in Slack APIs). +3. **Do NOT rename JSON fields to camelCase in the data layer.** If the API sends `thread_ts`, keep `thread_ts` in your interface. Only convert to camelCase at the application boundary if needed. This avoids serialization bugs and keeps types aligned with API docs. +4. Gson `TypeAdapter` / Jackson `@JsonTypeInfo` + `@JsonSubTypes` for polymorphic deserialization → TypeScript discriminated unions with a `type` field + Zod `z.discriminatedUnion()`. This is the most important pattern for Block Kit model conversion. +5. Gson `GsonBuilder().registerTypeAdapterFactory()` for a family of types → a single Zod discriminated union schema that handles all variants. No factory registration needed — Zod validates and narrows in one step. +6. Java `Date`/`Instant` serialized as epoch seconds or ISO strings → parse with `new Date(epoch * 1000)` or `new Date(isoString)`. Use `z.coerce.date()` in Zod for automatic string-to-Date conversion. +7. Gson `@Expose` / Jackson `@JsonIgnore` for selective serialization → TypeScript `Omit` utility type at the serialization boundary, or use a `toJSON()` method on classes. +8. Gson null handling (`serializeNulls()`) → JSON.stringify includes `null` by default but omits `undefined`. Use `null` (not `undefined`) for fields that must appear in the wire format. +9. Java `Map` deserialized as a catch-all → `z.record(z.string(), z.unknown())` for validated records, or `Record` for type-only. +10. Custom Gson deserializers that inspect JSON structure to decide the concrete type → Zod `.transform()` pipelines or preprocess functions that inspect the raw JSON before validating. +11. Jackson `@JsonCreator` / `@JsonProperty` constructor deserialization → just use Zod `.parse()` which returns a plain object matching the schema. No special constructor needed. +12. Large model hierarchies (like Slack's Block Kit: 15+ block types, 20+ element types) should use **one discriminated union per hierarchy level**, not one giant union. This keeps validation fast and error messages readable. + +## patterns + +### Gson @SerializedName → TypeScript interface with wire-format keys + +```java +// --- Before (Java with Gson) --- +public class SlackUser { + @SerializedName("user_id") + private String userId; + + @SerializedName("real_name") + private String realName; + + @SerializedName("is_admin") + private boolean isAdmin; + + @SerializedName("updated") + private long updatedTimestamp; + + // Gson auto-deserializes: {"user_id":"U123","real_name":"Alice","is_admin":true,"updated":1700000000} +} +``` + +```typescript +// --- After (TypeScript) --- +// Keep snake_case keys to match the API wire format +interface SlackUser { + user_id: string; + real_name: string; + is_admin: boolean; + updated: number; +} + +// Parse with no library — JSON.parse returns the right shape +const user: SlackUser = JSON.parse(responseBody); + +// With Zod for runtime validation (recommended for external API data): +import { z } from 'zod'; + +const SlackUserSchema = z.object({ + user_id: z.string(), + real_name: z.string(), + is_admin: z.boolean(), + updated: z.number(), +}); + +type SlackUser = z.infer; + +const user = SlackUserSchema.parse(JSON.parse(responseBody)); +``` + +### Gson polymorphic TypeAdapter → Zod discriminated union + +```java +// --- Before (Java with Gson) --- +// Custom factory for deserializing Block Kit blocks by "type" field +public class GsonLayoutBlockFactory implements JsonDeserializer { + @Override + public LayoutBlock deserialize(JsonElement json, Type typeOfT, JsonDeserializationContext ctx) { + String type = json.getAsJsonObject().get("type").getAsString(); + switch (type) { + case "section": return ctx.deserialize(json, SectionBlock.class); + case "actions": return ctx.deserialize(json, ActionsBlock.class); + case "divider": return ctx.deserialize(json, DividerBlock.class); + case "header": return ctx.deserialize(json, HeaderBlock.class); + case "image": return ctx.deserialize(json, ImageBlock.class); + case "context": return ctx.deserialize(json, ContextBlock.class); + case "input": return ctx.deserialize(json, InputBlock.class); + default: throw new JsonParseException("Unknown block type: " + type); + } + } +} + +// Registration: +Gson gson = new GsonBuilder() + .registerTypeAdapter(LayoutBlock.class, new GsonLayoutBlockFactory()) + .create(); + +List blocks = gson.fromJson(json, new TypeToken>(){}.getType()); +``` + +```typescript +// --- After (TypeScript with Zod) --- +import { z } from 'zod'; + +// Define each block variant schema +const SectionBlockSchema = z.object({ + type: z.literal('section'), + block_id: z.string().optional(), + text: z.object({ type: z.string(), text: z.string() }).optional(), + fields: z.array(z.object({ type: z.string(), text: z.string() })).optional(), + accessory: z.unknown().optional(), +}); + +const ActionsBlockSchema = z.object({ + type: z.literal('actions'), + block_id: z.string().optional(), + elements: z.array(z.unknown()), +}); + +const DividerBlockSchema = z.object({ + type: z.literal('divider'), + block_id: z.string().optional(), +}); + +const HeaderBlockSchema = z.object({ + type: z.literal('header'), + block_id: z.string().optional(), + text: z.object({ type: z.literal('plain_text'), text: z.string() }), +}); + +const ImageBlockSchema = z.object({ + type: z.literal('image'), + block_id: z.string().optional(), + image_url: z.string().url(), + alt_text: z.string(), +}); + +const ContextBlockSchema = z.object({ + type: z.literal('context'), + block_id: z.string().optional(), + elements: z.array(z.unknown()), +}); + +const InputBlockSchema = z.object({ + type: z.literal('input'), + block_id: z.string().optional(), + label: z.object({ type: z.literal('plain_text'), text: z.string() }), + element: z.unknown(), +}); + +// Discriminated union replaces the entire TypeAdapter factory +const LayoutBlockSchema = z.discriminatedUnion('type', [ + SectionBlockSchema, + ActionsBlockSchema, + DividerBlockSchema, + HeaderBlockSchema, + ImageBlockSchema, + ContextBlockSchema, + InputBlockSchema, +]); + +type LayoutBlock = z.infer; + +// Usage — replaces Gson.fromJson() + TypeToken +const blocks = z.array(LayoutBlockSchema).parse(JSON.parse(jsonString)); +// Each block is automatically narrowed by its `type` field +``` + +### Gson custom date handling → Zod coerce + +```java +// --- Before (Java) --- +// Custom Gson adapter for epoch seconds +GsonBuilder builder = new GsonBuilder(); +builder.registerTypeAdapter(Instant.class, (JsonDeserializer) (json, type, ctx) -> + Instant.ofEpochSecond(json.getAsLong())); +``` + +```typescript +// --- After (TypeScript with Zod) --- +const TimestampSchema = z.number().transform((epoch) => new Date(epoch * 1000)); + +// Or for ISO string dates: +const DateStringSchema = z.string().pipe(z.coerce.date()); + +// In a larger schema: +const EventSchema = z.object({ + type: z.string(), + event_ts: TimestampSchema, + created_at: DateStringSchema.optional(), +}); +``` + +## pitfalls + +- **Over-validating internal data**: Use Zod at system boundaries (API responses, webhook payloads, user input). Don't validate data you just created yourself — that's wasteful. +- **Renaming fields to camelCase**: Resist the urge to transform `thread_ts` to `threadTs` in the data model. Keep wire-format keys to avoid serialization/deserialization bugs and stay aligned with API docs. Transform at the UI/application boundary if needed. +- **Gson lenient mode**: Gson's `setLenient(true)` accepts malformed JSON. `JSON.parse()` is strict by default. If the source data is not strict JSON (trailing commas, single quotes), clean it before parsing. +- **Losing type narrowing**: Java's polymorphic deserialization returns the base type. TypeScript's discriminated unions + Zod automatically narrow to the specific variant. Leverage this — use `switch (block.type)` and TS will infer the variant type. +- **Huge union schemas**: A single `z.discriminatedUnion()` with 30+ variants is slow to compile and produces unreadable errors. Split into hierarchical unions: `LayoutBlock`, `BlockElement`, `TextObject`, etc. +- **`null` vs missing key**: Gson distinguishes between `"field": null` and absent `"field"`. In TS, both become `undefined` with `z.optional()`. Use `z.nullable()` if you need to distinguish null from missing. +- **Generic type tokens**: Java's `TypeToken>` for generic deserialization has no TS equivalent — it's not needed. `z.array(schema).parse(data)` handles generic arrays directly. +- **Circular references**: If Java models have circular references (A references B which references A), Gson handles this via lazy deserialization. Zod schemas can use `z.lazy()` for circular types, but redesign to break the cycle if possible. + +## references + +- https://zod.dev/ -- Zod schema validation library +- https://github.com/google/gson -- Gson (source library reference) +- https://github.com/FasterXML/jackson -- Jackson (source library reference) +- https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/JSON -- JSON built-in +- https://www.typescriptlang.org/docs/handbook/2/narrowing.html#discriminated-unions -- discriminated unions + +## instructions + +Use this expert when converting Java Gson/Jackson serialization code to TypeScript. The most critical pattern is polymorphic deserialization (TypeAdapter factories → Zod discriminated unions), which affects all Block Kit model conversion. Start by identifying all `@SerializedName` fields and custom TypeAdapter/JsonDeserializer classes, then map them to TypeScript interfaces + Zod schemas. Pair with `java-to-ts-ts.md` for general Java→TS conversion, `type-mapping-ts.md` for type system reference, and `../bridge/ui-block-kit-adaptive-cards-ts.md` if converting Block Kit models to Adaptive Cards. + +## research + +Deep Research prompt: + +"Write a micro expert for converting Java Gson/Jackson JSON serialization to TypeScript. Cover: @SerializedName to interface fields, polymorphic TypeAdapter factories to Zod discriminated unions, custom deserializers to Zod transforms, date/timestamp handling, null semantics, TypeToken generics elimination, and large model hierarchy strategies. Include worked examples for a Block Kit-style type hierarchy with 7+ variants." diff --git a/plugins/microsoft-365-agents-toolkit/skills/teams-app-developer/experts/convert/kotlin-to-ts-ts.md b/plugins/microsoft-365-agents-toolkit/skills/teams-app-developer/experts/convert/kotlin-to-ts-ts.md new file mode 100644 index 0000000..000142d --- /dev/null +++ b/plugins/microsoft-365-agents-toolkit/skills/teams-app-developer/experts/convert/kotlin-to-ts-ts.md @@ -0,0 +1,212 @@ +# kotlin-to-ts-ts + +## purpose + +Rewriting Kotlin source code as idiomatic TypeScript — covering Kotlin-specific syntax (trailing lambdas, null-safety operators, string templates, `it`, `when`, extension functions) that the Java-to-TS expert does not address. + +## rules + +1. Kotlin string templates (`"Hello $name"`, `"text ${expr}"`) map directly to TypeScript template literals (`` `Hello ${name}` ``, `` `text ${expr}` ``). Multi-line strings with `.trimIndent()` become TS template literals with no call needed — TS template literals already preserve literal indentation. +2. Kotlin trailing lambda syntax (`app.command("/echo") { req, ctx -> ... }`) maps to a callback argument: `app.command("/echo", async (req, ctx) => { ... })`. The lambda body `{ ... }` becomes `async (...) => { ... }` when the target API is async. +3. Kotlin SAM (Single Abstract Method) conversions — where a lambda replaces a single-method interface — map to TypeScript function arguments directly. `app.event(handler)` where `handler` is a lambda becomes `app.on("event", async (ctx) => { ... })`. +4. Kotlin `it` (implicit single-parameter lambda) must be given an explicit name in TS. `list.filter { it.isActive }` becomes `list.filter((item) => item.isActive)`. Choose a meaningful name from context (`req`, `ctx`, `msg`, `user`, etc.). +5. Kotlin null-safety operator `?.` maps to TS optional chaining `?.`. Kotlin `!!` (non-null assertion) maps to TS `!` (non-null assertion). Kotlin elvis `?:` maps to TS nullish coalescing `??`. Examples: `user?.name` → `user?.name`, `value!!` → `value!`, `name ?: "default"` → `name ?? "default"`. +6. Kotlin `when` expressions map to TS `switch` statements or chained ternaries. `when` with no subject (boolean conditions) maps to `if/else if`. `when` with a subject (value matching) maps to `switch`. If used as an expression (assigned to a variable), prefer chained ternaries or an IIFE wrapping a `switch`. +7. Kotlin `val` maps to `const` (for locals) or `readonly` (for class fields). Kotlin `var` maps to `let`. Never use `var` in the TS output — always `const` or `let`. +8. Kotlin `fun` at package level (top-level functions) maps to TS exported functions: `export function myFn() { ... }`. Kotlin does not require a wrapping class for top-level functions, and neither does TS. +9. Kotlin extension functions (`fun String.toSlug(): String`) have no direct TS equivalent. Convert to a standalone utility function: `function toSlug(s: string): string`. If the extension is on a project type, consider adding a method to the class instead. +10. Kotlin `data class` maps to a TypeScript `interface` (for pure data) or a `class` with constructor shorthand (if methods are needed). `data class User(val name: String, val email: String)` → `interface User { readonly name: string; readonly email: string; }`. Destructuring `val (name, email) = user` → `const { name, email } = user`. +11. Kotlin `object` declarations (singletons) map to a plain TS module-level `const` object or a namespace. `object Config { val port = 3000 }` → `const Config = { port: 3000 } as const`. Kotlin `companion object` maps to `static` members on the class or module-level constants. +12. Kotlin `sealed class` / `sealed interface` maps to TS discriminated union types. `sealed class Result` with subclasses `Success` and `Error` → `type Result = { kind: 'success'; value: T } | { kind: 'error'; error: string }`. +13. Kotlin scope functions (`let`, `run`, `apply`, `also`, `with`) should be inlined rather than translated literally. `user?.let { sendEmail(it) }` → `if (user) sendEmail(user)`. `config.apply { port = 3000; host = "localhost" }` → direct property assignments. +14. Kotlin `listOf()`, `mapOf()`, `mutableListOf()`, `mutableMapOf()` map to TS array/object literals: `listOf("a", "b")` → `["a", "b"]`, `mapOf("key" to "value")` → `{ key: "value" }` or `new Map([["key", "value"]])`. +15. Kotlin `for (item in list)` maps to `for (const item of list)`. Kotlin ranges `for (i in 0 until n)` → `for (let i = 0; i < n; i++)`. Kotlin `for (i in 0..n)` (inclusive) → `for (let i = 0; i <= n; i++)`. +16. Kotlin type casts: `as` (unsafe cast) → TS `as` (type assertion). `as?` (safe cast) → TS has no direct equivalent; use a type guard function or conditional check. +17. Kotlin `::class.java` / `SomeClass::class.java` (class references for reflection) should be removed. In the Slack Bolt SDK, `app.event(AppMentionEvent::class.java) { ... }` becomes a string-based route: `app.on("message", async (ctx) => { ... })` in Teams. + +## patterns + +### Trailing lambda + ack pattern (Slack Bolt → Teams) + +```kotlin +// --- Before (Kotlin) --- +app.command("/echo") { req, ctx -> + val text = "You said ${req.payload.text} at <#${req.payload.channelId}|${req.payload.channelName}>" + ctx.respond { it.text(text) } + ctx.ack() +} +``` + +```typescript +// --- After (TypeScript, Teams SDK) --- +app.on('message', async ({ activity, send }) => { + const text = `You said ${activity.text}`; + await send(text); +}); +``` + +### Null-safety chain + +```kotlin +// --- Before (Kotlin) --- +val hash = event.event.view?.hash +val name = user?.profile?.displayName ?: "Unknown" +val id = data!!.userId +``` + +```typescript +// --- After (TypeScript) --- +const hash = event.event.view?.hash; +const name = user?.profile?.displayName ?? 'Unknown'; +const id = data!.userId; +``` + +### String templates + trimIndent + +```kotlin +// --- Before (Kotlin) --- +val view = """ +{ + "type": "home", + "blocks": [ + { + "type": "section", + "text": { + "type": "mrkdwn", + "text": "Hello ${user.name}! Updated: ${ZonedDateTime.now()}" + } + } + ] +} +""".trimIndent() +``` + +```typescript +// --- After (TypeScript) --- +const view = { + type: 'home' as const, + blocks: [ + { + type: 'section', + text: { + type: 'mrkdwn', + text: `Hello ${user.name}! Updated: ${new Date().toISOString()}`, + }, + }, + ], +}; +// Prefer a typed object over a JSON string when the target SDK accepts objects. +// If a raw string is truly needed: +const viewJson = JSON.stringify(view); +``` + +### When expression → switch + +```kotlin +// --- Before (Kotlin) --- +val response = when (action) { + "approve" -> "Approved!" + "reject" -> "Rejected." + "defer" -> "Deferred to next week." + else -> "Unknown action: $action" +} +``` + +```typescript +// --- After (TypeScript) --- +let response: string; +switch (action) { + case 'approve': + response = 'Approved!'; + break; + case 'reject': + response = 'Rejected.'; + break; + case 'defer': + response = 'Deferred to next week.'; + break; + default: + response = `Unknown action: ${action}`; +} +``` + +### Scope function inlining + +```kotlin +// --- Before (Kotlin) --- +val result = config.apply { + port = 3000 + host = "localhost" +} + +user?.let { ctx.say("Hello ${it.name}") } + +val mapped = items.map { it.name to it.value }.toMap() +``` + +```typescript +// --- After (TypeScript) --- +const config = { port: 3000, host: 'localhost' }; + +if (user) { + await send(`Hello ${user.name}`); +} + +const mapped = Object.fromEntries(items.map((item) => [item.name, item.value])); +``` + +### Object declaration / companion object + +```kotlin +// --- Before (Kotlin) --- +class ResourceLoader { + companion object { + fun loadAppConfig(name: String = "appConfig.json"): AppConfig { + // ... + } + } +} +// Usage: ResourceLoader.loadAppConfig() +``` + +```typescript +// --- After (TypeScript) --- +// Companion object → module-level function (no class wrapper needed) +export function loadAppConfig(name = 'appConfig.json'): AppConfig { + // ... +} +// Usage: loadAppConfig() +``` + +## pitfalls + +- **Forgetting to name `it`**: Every Kotlin `it` reference must get an explicit TS parameter name. Blindly searching for `it` will produce false positives on English words — search for `{ it.` and `{ it ->` patterns. +- **`trimIndent()` on JSON strings**: Kotlin examples often build JSON as `.trimIndent()` multiline strings. In TS, prefer a typed object literal instead of a string. If the target API needs a string, use `JSON.stringify(obj)` for safety over manual template literals. +- **`!!` overuse**: Kotlin's `!!` means "throw if null". TS's `!` is only a compile-time assertion — it does NOT throw at runtime. If the Kotlin code relies on `!!` for runtime safety, add an explicit null check instead. +- **`as?` safe cast**: Kotlin's `as?` returns `null` if the cast fails. TS's `as` never fails at runtime (it's a compile-time assertion). Translate `as?` to a type guard check, not a bare `as`. +- **Trailing lambda position**: Kotlin allows the last lambda argument to be outside the parentheses. In TS, ALL arguments go inside the parentheses. `app.command("/echo") { req, ctx -> }` → `app.command("/echo", async (req, ctx) => { })`. +- **`listOf()` / `mapOf()` immutability**: Kotlin's `listOf()` returns an immutable list. TS arrays are mutable by default. If immutability matters, use `as const` or `ReadonlyArray`. +- **Class reference syntax**: `SomeClass::class.java` in Kotlin (used for event type registration in Slack Bolt) has no TS equivalent. Replace with the string event name expected by the target SDK. +- **Extension functions on primitives**: Kotlin can extend `String`, `Int`, etc. TS cannot extend primitive types. Always convert to standalone functions. +- **Destructuring data classes**: Kotlin `val (a, b) = pair` uses `componentN()` functions. TS destructuring uses property names: `const { first, second } = pair`. The names must match. + +## references + +- https://kotlinlang.org/docs/basic-syntax.html — Kotlin syntax reference +- https://kotlinlang.org/docs/null-safety.html — Kotlin null-safety operators +- https://kotlinlang.org/docs/lambdas.html — Kotlin lambda syntax and SAM conversions +- https://kotlinlang.org/docs/scope-functions.html — let, run, apply, also, with +- https://kotlinlang.org/docs/data-classes.html — Data classes +- https://kotlinlang.org/docs/extensions.html — Extension functions +- https://www.typescriptlang.org/docs/handbook/2/template-literal-types.html — TS template literals +- https://www.typescriptlang.org/docs/handbook/2/narrowing.html — TS type narrowing and guards + +## instructions + +Use this expert when the source code is Kotlin (`.kt` files). It handles Kotlin-specific syntax that the `java-to-ts-ts.md` expert does not cover: trailing lambdas, `it` implicit parameters, string templates, `trimIndent()`, null-safety operators (`?.`, `!!`, `?:`), `when` expressions, scope functions, extension functions, `data class`, `object`/`companion object`, sealed classes, and `::class.java` references. For Java SDK types, generics, collections, and Lombok patterns, pair with `java-to-ts-ts.md`. For type system mapping, pair with `type-mapping-ts.md`. + +## research + +Deep Research prompt: + +"Write a micro expert on converting Kotlin to TypeScript. Cover: string templates to template literals, trailing lambda syntax to callback arguments, SAM conversions, it implicit parameter, null-safety operators (?. !! ?:) to optional chaining/nullish coalescing/non-null assertion, when expressions to switch, val/var to const/let, extension functions to utility functions, data class to interface, object declarations to module constants, sealed class to discriminated unions, scope functions (let/run/apply/also/with) inlining, and class reference syntax removal. Include 4-5 worked side-by-side examples." diff --git a/plugins/microsoft-365-agents-toolkit/skills/teams-app-developer/experts/convert/ruby-to-ts-ts.md b/plugins/microsoft-365-agents-toolkit/skills/teams-app-developer/experts/convert/ruby-to-ts-ts.md new file mode 100644 index 0000000..2cbf207 --- /dev/null +++ b/plugins/microsoft-365-agents-toolkit/skills/teams-app-developer/experts/convert/ruby-to-ts-ts.md @@ -0,0 +1,224 @@ +# ruby-to-ts-ts + +## purpose + +Rewriting Ruby source code as idiomatic TypeScript — mapping Ruby language constructs, OOP patterns, metaprogramming, and common idioms to their TypeScript equivalents. + +## rules + +1. Ruby blocks (`do...end` / `{ |x| }`) map to arrow functions. `array.each { |item| puts item }` becomes `array.forEach((item) => console.log(item))`. Ruby's `yield` inside methods maps to calling a callback parameter. +2. Ruby mixins (`include Module`) map to TypeScript interfaces + composition. Do NOT use class inheritance to simulate mixins — use interface implementation with helper functions or the mixin pattern (`applyMixins`). +3. Ruby duck typing maps to TypeScript structural typing. If Ruby code checks `obj.respond_to?(:method)`, define an interface with that method and use a type guard: `function hasMethod(obj: unknown): obj is HasMethod`. +4. Ruby `attr_accessor :name` maps to a class property with TypeScript accessor shorthand: `constructor(public name: string) {}` or explicit `get`/`set` pairs if logic is needed. +5. Ruby symbols (`:name`) map to string literal types or enum members. A method accepting `type: :admin | :user` becomes `type: 'admin' | 'user'` in TypeScript. +6. Ruby hashes (`{ key: value }`) map to TypeScript objects or `Record`. Named-parameter hashes (`def method(opts = {})`) become destructured typed parameters: `function method({ key1, key2 }: Options)`. +7. Ruby `nil` maps to `null` or `undefined`. Use `null` for explicit absence and `undefined` for optional/missing. Ruby's `&.` safe navigator maps to optional chaining (`?.`). +8. Ruby `begin/rescue/ensure` maps to `try/catch/finally`. Ruby's typed rescue (`rescue TypeError => e`) maps to catching and narrowing: `catch (e) { if (e instanceof TypeError) ... }`. +9. Ruby open classes and monkey-patching have NO TypeScript equivalent. Redesign as wrapper functions, decorator patterns, or module augmentation (`declare module` for extending third-party types). +10. Ruby metaprogramming (`define_method`, `method_missing`, `send`) has no direct equivalent. Replace `define_method` loops with computed property patterns or factory functions. Replace `method_missing` with `Proxy` objects (sparingly) or explicit handler maps. +11. Ruby's `Enumerable` methods map to JavaScript array methods: `map`→`map`, `select`→`filter`, `reject`→`filter` (inverted), `reduce`→`reduce`, `detect`/`find`→`find`, `flat_map`→`flatMap`, `each_with_object`→`reduce`, `group_by`→custom `groupBy` or `Object.groupBy()`. +12. Ruby string interpolation `"Hello #{name}"` maps to template literals `` `Hello ${name}` ``. +13. Ruby `Proc.new` / `lambda` / `->` all map to arrow functions. Ruby's distinction between procs and lambdas (arity checking, return behavior) disappears — TypeScript arrow functions always behave like Ruby lambdas. +14. Ruby modules used as namespaces map to TypeScript modules (files) with named exports. Do NOT use TypeScript `namespace` keyword — use ES module `export` instead. + +## patterns + +### Ruby class with mixins → TypeScript interface + composition + +```ruby +# --- Before (Ruby) --- +module Greetable + def greet + "Hello, I'm #{name}" + end +end + +module Trackable + def track(event) + puts "Tracking #{event} for #{name}" + end +end + +class User + include Greetable + include Trackable + + attr_accessor :name, :email + + def initialize(name, email) + @name = name + @email = email + end +end + +user = User.new("Alice", "alice@example.com") +puts user.greet +user.track("login") +``` + +```typescript +// --- After (TypeScript) --- +interface Greetable { + name: string; + greet(): string; +} + +function greetMixin(obj: T): T & Greetable { + return Object.assign(obj, { + greet() { + return `Hello, I'm ${obj.name}`; + }, + }); +} + +interface Trackable { + name: string; + track(event: string): void; +} + +function trackMixin(obj: T): T & Trackable { + return Object.assign(obj, { + track(event: string) { + console.log(`Tracking ${event} for ${obj.name}`); + }, + }); +} + +class User { + constructor( + public name: string, + public email: string, + ) {} +} + +// Apply mixins +function createUser(name: string, email: string): User & Greetable & Trackable { + const user = new User(name, email); + return trackMixin(greetMixin(user)); +} + +const user = createUser("Alice", "alice@example.com"); +console.log(user.greet()); +user.track("login"); +``` + +### Ruby hash options / keyword args → TypeScript typed parameters + +```ruby +# --- Before (Ruby) --- +class SlackNotifier + def initialize(opts = {}) + @webhook_url = opts[:webhook_url] || ENV['SLACK_WEBHOOK'] + @channel = opts[:channel] || '#general' + @username = opts[:username] || 'bot' + end + + def notify(message, opts = {}) + icon = opts.fetch(:icon_emoji, ':robot_face:') + thread_ts = opts[:thread_ts] + # ... send notification + end +end + +notifier = SlackNotifier.new(webhook_url: 'https://...', channel: '#alerts') +notifier.notify('Deploy complete', icon_emoji: ':rocket:') +``` + +```typescript +// --- After (TypeScript) --- +interface SlackNotifierOptions { + webhookUrl?: string; + channel?: string; + username?: string; +} + +interface NotifyOptions { + iconEmoji?: string; + threadTs?: string; +} + +class SlackNotifier { + private readonly webhookUrl: string; + private readonly channel: string; + private readonly username: string; + + constructor({ + webhookUrl = process.env.SLACK_WEBHOOK ?? '', + channel = '#general', + username = 'bot', + }: SlackNotifierOptions = {}) { + this.webhookUrl = webhookUrl; + this.channel = channel; + this.username = username; + } + + notify(message: string, { iconEmoji = ':robot_face:', threadTs }: NotifyOptions = {}): void { + // ... send notification + } +} + +const notifier = new SlackNotifier({ webhookUrl: 'https://...', channel: '#alerts' }); +notifier.notify('Deploy complete', { iconEmoji: ':rocket:' }); +``` + +### Ruby Enumerable → TypeScript array methods + +```ruby +# --- Before (Ruby) --- +users = get_users() +active_admins = users + .select { |u| u.active? } + .reject { |u| u.guest? } + .select { |u| u.role == :admin } + .map { |u| { name: u.name, email: u.email } } + .sort_by { |h| h[:name] } +``` + +```typescript +// --- After (TypeScript) --- +interface User { + name: string; + email: string; + active: boolean; + guest: boolean; + role: 'admin' | 'user' | 'guest'; +} + +const users: User[] = getUsers(); +const activeAdmins = users + .filter((u) => u.active) + .filter((u) => !u.guest) + .filter((u) => u.role === 'admin') + .map((u) => ({ name: u.name, email: u.email })) + .sort((a, b) => a.name.localeCompare(b.name)); +``` + +## pitfalls + +- **Ruby truthiness vs JS truthiness**: In Ruby, only `nil` and `false` are falsy. In JS/TS, `0`, `""`, `NaN`, `null`, `undefined`, and `false` are all falsy. Ruby code like `if count` (truthy when 0) must become `if (count !== null && count !== undefined)` in TS. +- **Ruby `==` is value equality; JS `===` is identity for objects**: Ruby `==` on strings/numbers compares values. TS `===` on primitives works the same, but on objects it compares references. Deep equality requires a library or custom check. +- **`each` return value**: Ruby's `each` returns the original array. JS `forEach` returns `undefined`. Don't chain after `forEach`. +- **Ruby ranges (`1..10`)**: No TS equivalent. Use `Array.from({ length: 10 }, (_, i) => i + 1)` or a simple `for` loop. +- **String is mutable in Ruby, immutable in JS**: Ruby `str.gsub!` mutates in place. TS strings are immutable — always reassign: `str = str.replace(...)`. +- **Ruby exception hierarchy**: Ruby has `StandardError`, `RuntimeError`, etc. TS/JS only has `Error`. Use custom error classes extending `Error` if you need a hierarchy. +- **Snake_case to camelCase**: Ruby uses `snake_case` for methods/variables. TypeScript convention is `camelCase`. Convert all identifiers, but keep API payloads in their original format (e.g., Slack payloads use `snake_case`). +- **Ruby `require` is file-level, not scoped**: All Ruby `require` statements load globally. TS `import` is scoped to the file. This means Ruby's implicit global availability must become explicit imports in every file that uses the dependency. +- **Sinatra/Rack → Express**: Ruby Sinatra routes (`get '/' do ... end`) map to Express (`app.get('/', (req, res) => { ... })`). The middleware patterns are similar but request/response APIs differ completely. + +## references + +- https://www.typescriptlang.org/docs/handbook/2/classes.html -- TS classes +- https://www.typescriptlang.org/docs/handbook/2/objects.html -- structural typing +- https://www.typescriptlang.org/docs/handbook/mixins.html -- mixin pattern +- https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array -- JS array methods (Enumerable equivalents) +- https://ruby-doc.org/core/Enumerable.html -- Ruby Enumerable reference (for mapping) + +## instructions + +Use this expert when rewriting Ruby source code in TypeScript. Start by identifying the Ruby constructs in use (classes, modules/mixins, blocks, metaprogramming, Enumerable chains) and map each to its TS equivalent using the rules above. Pay special attention to truthiness differences, mixin patterns, and naming convention changes (snake_case → camelCase). Pair with `dependency-mapping-ts.md` for gem → npm package equivalents, and `type-mapping-ts.md` for cross-language type reference. + +## research + +Deep Research prompt: + +"Write a micro expert on converting Ruby to TypeScript. Cover: blocks to arrow functions, mixins to interfaces/composition, duck typing to structural typing, attr_accessor to class properties, symbol to string literals, hash options to typed parameters, metaprogramming alternatives, Enumerable methods to array methods, exception handling, truthiness differences, and naming convention conversion (snake_case to camelCase). Include 3 worked examples." diff --git a/plugins/microsoft-365-agents-toolkit/skills/teams-app-developer/experts/convert/type-mapping-ts.md b/plugins/microsoft-365-agents-toolkit/skills/teams-app-developer/experts/convert/type-mapping-ts.md new file mode 100644 index 0000000..89aab96 --- /dev/null +++ b/plugins/microsoft-365-agents-toolkit/skills/teams-app-developer/experts/convert/type-mapping-ts.md @@ -0,0 +1,214 @@ +# type-mapping-ts + +## purpose + +Cross-language type system mapping reference — translating type concepts from JavaScript, Ruby, and Java into idiomatic TypeScript types, covering primitives, nullability, generics, collections, enums, and structural patterns. + +## rules + +1. Map primitive types using the canonical table below. TypeScript uses lowercase for primitives (`string`, `number`, `boolean`) — never use the wrapper types (`String`, `Number`, `Boolean`). +2. Nullable types: Java `@Nullable T` and Ruby's implicit nil-ability both map to `T | null`. For optional parameters/properties, use `T | undefined` (or the `?` optional marker). Distinguish between "explicitly null" and "not provided". +3. Generic type parameters use the same `` syntax across Java and TypeScript. Ruby has no generics — infer types from usage patterns and add explicit generic parameters during conversion. +4. Collection types: Java `List` → `T[]`, Java `Map` → `Map` or `Record`, Java `Set` → `Set`. Ruby `Array` → `T[]`, Ruby `Hash` → `Record` or `Map`. +5. Union types (`A | B`) are TypeScript's killer feature with no direct Java or Ruby equivalent. Use them liberally to replace: Java method overloading, Ruby duck-typed parameters that accept multiple types, and stringly-typed fields. +6. Discriminated unions replace Java's visitor pattern and Ruby's case-when on class type. Add a `type` or `kind` literal field to each variant for exhaustive narrowing. +7. TypeScript `unknown` is safer than `any`. Use `unknown` for values from external sources (API responses, user input, parsed JSON) and narrow with type guards. Reserve `any` for temporary migration scaffolding. +8. Ruby symbols (`:name`) and Java string constants (`public static final String`) both map to string literal types: `type Role = 'admin' | 'user' | 'guest'`. +9. Java `void` maps to TypeScript `void`. Ruby methods that return `nil` implicitly map to `void` return type (or `T | undefined` if the nil return is meaningful). +10. Tuple types (`[string, number]`) are useful when converting Ruby methods that return multiple values (`return name, age`) or Java `Pair` / `Map.Entry`. +11. Use `readonly` modifier for properties that were `final` in Java or `freeze`-d in Ruby. Use `Readonly` utility type for deeply immutable objects. +12. Index signatures (`[key: string]: T`) replace Java's `Map` and Ruby's open hashes when the key set is not known at compile time. + +## patterns + +### Primitive type mapping table + +| Concept | Java | Ruby | JavaScript | TypeScript | +|---|---|---|---|---| +| String | `String` | `String` | `string` | `string` | +| Integer | `int` / `Integer` | `Integer` / `Fixnum` | `number` | `number` | +| Float | `double` / `Double` / `float` | `Float` | `number` | `number` | +| Big integer | `BigInteger` / `long` | `Bignum` | `bigint` | `bigint` | +| Boolean | `boolean` / `Boolean` | `TrueClass`/`FalseClass` | `boolean` | `boolean` | +| Null | `null` | `nil` | `null` | `null` | +| Undefined | N/A | N/A | `undefined` | `undefined` | +| Void | `void` | implicit nil | `undefined` | `void` | +| Any/Object | `Object` | `Object` | `any` | `unknown` (preferred) or `any` | +| Byte array | `byte[]` | `String` (binary) | `Uint8Array` | `Uint8Array` or `Buffer` | +| Date/Time | `LocalDateTime` / `Instant` | `Time` / `DateTime` | `Date` | `Date` or `Temporal` (stage 3) | +| Regex | `Pattern` | `Regexp` | `RegExp` | `RegExp` | +| Symbol | N/A | `Symbol` (`:name`) | `symbol` / string | string literal type | + +### Collection type mapping table + +| Concept | Java | Ruby | TypeScript | +|---|---|---|---| +| Ordered list | `List` / `ArrayList` | `Array` | `T[]` or `Array` | +| Fixed-size tuple | `Pair` / `record` (Java 16+) | `[a, b]` array | `[A, B]` tuple | +| Key-value map (string keys) | `Map` | `Hash` | `Record` | +| Key-value map (any keys) | `Map` | `Hash` | `Map` | +| Unique set | `Set` / `HashSet` | `Set` | `Set` | +| Queue | `Queue` / `Deque` | `Array` (push/shift) | `T[]` (push/shift) | +| Immutable list | `List.of()` / `Collections.unmodifiable` | `freeze` | `readonly T[]` or `ReadonlyArray` | +| Immutable map | `Map.of()` | `freeze` | `Readonly>` | + +### Nullability pattern mapping + +```typescript +// Java Optional → TypeScript +// Java: Optional findUser(String id) +// Ruby: def find_user(id) → User or nil +// TS: +function findUser(id: string): User | null { + const user = db.get(id); + return user ?? null; +} + +// Java Optional chain → TypeScript optional chaining +// Java: user.flatMap(u -> u.getAddress()).map(a -> a.getCity()).orElse("Unknown") +// Ruby: user&.address&.city || "Unknown" +// TS: +const city = user?.address?.city ?? 'Unknown'; + +// Java @Nullable parameter → TypeScript optional parameter +// Java: void send(String msg, @Nullable String channel) +// Ruby: def send(msg, channel = nil) +// TS: +function send(msg: string, channel?: string): void { + const target = channel ?? '#general'; + // ... +} +``` + +### Discriminated union (replaces Java visitor / Ruby case-when on type) + +```java +// --- Java (before) --- +// Visitor pattern with 3 message types +public interface MessageVisitor { + void visit(TextMessage msg); + void visit(CardMessage msg); + void visit(FileMessage msg); +} + +public abstract class Message { + public abstract void accept(MessageVisitor visitor); +} +``` + +```ruby +# --- Ruby (before) --- +# Case-when on class type +case message +when TextMessage + handle_text(message) +when CardMessage + handle_card(message) +when FileMessage + handle_file(message) +end +``` + +```typescript +// --- TypeScript (after) --- +// Discriminated union replaces both patterns +interface TextMessage { + kind: 'text'; + content: string; +} + +interface CardMessage { + kind: 'card'; + cardJson: Record; +} + +interface FileMessage { + kind: 'file'; + url: string; + mimeType: string; +} + +type Message = TextMessage | CardMessage | FileMessage; + +function handleMessage(msg: Message): void { + switch (msg.kind) { + case 'text': + console.log(msg.content); // TS narrows to TextMessage + break; + case 'card': + renderCard(msg.cardJson); // TS narrows to CardMessage + break; + case 'file': + downloadFile(msg.url); // TS narrows to FileMessage + break; + } + // Exhaustive — adding a new variant causes a compile error +} +``` + +### Generics mapping + +```java +// --- Java (before) --- +public class Repository { + private final Map store = new HashMap<>(); + + public Optional findById(String id) { + return Optional.ofNullable(store.get(id)); + } + + public List findAll(Predicate filter) { + return store.values().stream() + .filter(filter) + .collect(Collectors.toList()); + } +} +``` + +```typescript +// --- TypeScript (after) --- +interface Entity { + id: string; +} + +class Repository { + private readonly store = new Map(); + + findById(id: string): T | null { + return this.store.get(id) ?? null; + } + + findAll(filter: (item: T) => boolean): T[] { + return [...this.store.values()].filter(filter); + } +} +``` + +## pitfalls + +- **`number` covers both int and float**: TypeScript has no integer type. If integer precision matters (IDs, counters), document the expectation or use `bigint` for very large values. +- **`null` vs `undefined` confusion**: Pick a convention. Recommendation: `undefined` for "optional/missing" (function params, object properties), `null` for "explicitly empty" (API responses, database NULLs). +- **Wrapper types**: Never use `String`, `Number`, `Boolean` as types in TypeScript. Always use lowercase `string`, `number`, `boolean`. +- **Java `int` overflow**: Java `int` is 32-bit; TypeScript `number` is 64-bit float. Values above `Number.MAX_SAFE_INTEGER` (2^53-1) lose precision. Use `bigint` if the Java code relies on exact large integer arithmetic. +- **Ruby's open type system**: Ruby allows adding methods to any object at runtime. TypeScript's type system is closed at compile time. Methods discovered via `method_missing` or `define_method` must be predefined in interfaces. +- **Enum pitfalls**: TypeScript numeric enums have reverse mapping (`Priority[1] === 'HIGH'`), which is usually unexpected. Prefer string literal unions or `as const` objects. +- **Generic variance**: Java has `? extends T` (covariant) and `? super T` (contravariant). TypeScript uses structural subtyping and generally infers variance. Explicit variance annotations (`in`/`out` modifiers) exist but are rarely needed. +- **Date handling**: Java's `java.time` and Ruby's `Time`/`DateTime` are far richer than JS `Date`. For serious date work, use `date-fns` or `luxon` rather than relying on the built-in `Date`. + +## references + +- https://www.typescriptlang.org/docs/handbook/2/everyday-types.html -- basic types +- https://www.typescriptlang.org/docs/handbook/2/narrowing.html -- type narrowing and guards +- https://www.typescriptlang.org/docs/handbook/2/generics.html -- generics +- https://www.typescriptlang.org/docs/handbook/utility-types.html -- Readonly, Partial, Pick, etc. +- https://www.typescriptlang.org/docs/handbook/2/types-from-types.html -- advanced type construction + +## instructions + +Use this expert as a cross-language type reference when converting from any source language to TypeScript. Consult the primitive and collection mapping tables first, then use the nullability and generics patterns for complex type scenarios. This expert is a dependency of all three language-specific conversion experts — they reference it for type translation questions. Pair with the appropriate language expert (`js-to-ts-ts.md`, `ruby-to-ts-ts.md`, or `java-to-ts-ts.md`) for language-specific idiom conversion beyond types. + +## research + +Deep Research prompt: + +"Write a micro expert for cross-language type mapping to TypeScript. Cover: primitive type mapping from Java/Ruby/JS to TS, collection type mapping (List, Map, Set, Queue), nullability patterns (Optional, nil, null/undefined), generic type parameter translation, discriminated unions replacing visitor/case-when patterns, enum mapping strategies, and common type system pitfalls when converting from statically-typed (Java) and dynamically-typed (Ruby/JS) languages." diff --git a/plugins/microsoft-365-agents-toolkit/skills/teams-app-developer/experts/deploy/aws-bot-deploy-ts.md b/plugins/microsoft-365-agents-toolkit/skills/teams-app-developer/experts/deploy/aws-bot-deploy-ts.md new file mode 100644 index 0000000..c665d94 --- /dev/null +++ b/plugins/microsoft-365-agents-toolkit/skills/teams-app-developer/experts/deploy/aws-bot-deploy-ts.md @@ -0,0 +1,263 @@ +# aws-bot-deploy-ts + +## purpose + +Step-by-step deployment of a Slack bot or Teams bot to AWS. Covers AWS CLI setup, IAM configuration, compute provisioning (Lambda + API Gateway / EC2 / ECS Fargate), environment configuration, and verification. Teams bots on AWS still require an Azure Bot Service registration for the Bot Framework messaging endpoint. + +## rules + +1. **Install prerequisites.** You need: Node.js 20 LTS, AWS CLI v2, and optionally AWS SAM CLI (`pip install aws-sam-cli`) or AWS CDK (`npm install -g aws-cdk`). Verify with `aws --version` and `node --version`. [docs.aws.amazon.com/cli/latest/userguide/getting-started-install.html](https://docs.aws.amazon.com/cli/latest/userguide/getting-started-install.html) +2. **Configure AWS credentials.** Run `aws configure` and enter your IAM access key, secret key, default region, and output format. For SSO-enabled organizations, use `aws sso login` instead. Verify with `aws sts get-caller-identity`. [docs.aws.amazon.com/cli/latest/userguide/cli-configure-quickstart.html](https://docs.aws.amazon.com/cli/latest/userguide/cli-configure-quickstart.html) +3. **Create an IAM user or role for the bot.** The bot's execution role needs permissions for: CloudWatch Logs (logging), Secrets Manager or SSM Parameter Store (credentials), and any other AWS services it accesses. Use least-privilege — don't give the bot AdministratorAccess. [docs.aws.amazon.com/IAM/latest/UserGuide/best-practices.html](https://docs.aws.amazon.com/IAM/latest/UserGuide/best-practices.html) +4. **Create a Slack API app at api.slack.com.** Under "OAuth & Permissions", install the app to your workspace and copy the Bot User OAuth Token (`xoxb-...`). Under "Basic Information", copy the Signing Secret. For Socket Mode, also create an App-Level Token (`xapp-...`). [api.slack.com/authentication/basics](https://api.slack.com/authentication/basics) +5. **Choose your compute target.** Lambda + API Gateway (serverless, event-driven — best for HTTP-mode Slack bots), EC2 or Elastic Beanstalk (always-on — required for Socket Mode, good for Teams bots), or ECS Fargate (containerized, production-grade). [docs.aws.amazon.com/lambda/latest/dg/welcome.html](https://docs.aws.amazon.com/lambda/latest/dg/welcome.html) +6. **For Lambda: use SAM or CDK to define the stack.** A SAM template defines the Lambda function + API Gateway in YAML. `sam build && sam deploy --guided` handles packaging, uploading, and CloudFormation stack creation. [docs.aws.amazon.com/serverless-application-model/latest/developerguide/what-is-sam.html](https://docs.aws.amazon.com/serverless-application-model/latest/developerguide/what-is-sam.html) +7. **For Lambda: handle the Slack 3-second ack deadline.** Lambda cold starts can take 1-5 seconds. Use provisioned concurrency (`ProvisionedConcurrencyConfig` in SAM) to keep warm instances, or use the async pattern: immediately return 200 to ack, then process via SQS + a second Lambda. [docs.aws.amazon.com/lambda/latest/dg/provisioned-concurrency.html](https://docs.aws.amazon.com/lambda/latest/dg/provisioned-concurrency.html) +8. **Socket Mode cannot run on Lambda.** Socket Mode requires a persistent WebSocket connection — Lambda functions are ephemeral. Use EC2, Elastic Beanstalk, or ECS Fargate for Socket Mode bots. HTTP-mode Slack bots work fine on Lambda. +9. **Store secrets in Secrets Manager or SSM Parameter Store.** Never put `SLACK_BOT_TOKEN` or `CLIENT_SECRET` in Lambda environment variables in plaintext for production. Use the SDK to fetch secrets at runtime: `const client = new SecretsManagerClient({}); const secret = await client.send(new GetSecretValueCommand({ SecretId: "bot/slack" }))`. [docs.aws.amazon.com/secretsmanager/latest/userguide/intro.html](https://docs.aws.amazon.com/secretsmanager/latest/userguide/intro.html) +10. **For Teams bots on AWS: you still need Azure Bot Service.** Register an App Registration in Entra ID (Azure AD), create a Bot Service resource, and set the messaging endpoint to your AWS URL (e.g., `https://.execute-api..amazonaws.com/api/messages`). Configure `MicrosoftAppId` and `MicrosoftAppPassword` in your AWS environment. [learn.microsoft.com/azure/bot-service/bot-service-quickstart-registration](https://learn.microsoft.com/azure/bot-service/bot-service-quickstart-registration) +11. **Configure Slack app URLs after deployment.** Once your API Gateway or EC2 instance is live, set the Event Subscriptions Request URL and Interactivity URL in the Slack app dashboard to your endpoint (e.g., `https://.execute-api..amazonaws.com/slack/events`). Slack sends a verification challenge immediately — the app must be running. +12. **Set up CloudWatch alarms for error monitoring.** Create alarms for Lambda errors (`Errors` metric > 0), API Gateway 5xx responses, and invocation duration. Use `aws cloudwatch put-metric-alarm` or define them in your SAM/CDK template. [docs.aws.amazon.com/AmazonCloudWatch/latest/monitoring/AlarmThatSendsEmail.html](https://docs.aws.amazon.com/AmazonCloudWatch/latest/monitoring/AlarmThatSendsEmail.html) + +## interview + +### Q1 — Compute Target +``` +question: "Which AWS compute target do you want to deploy to?" +header: "Compute" +options: + - label: "Lambda + API Gateway (Recommended)" + description: "Serverless, pay-per-invocation. Best for HTTP-mode Slack bots. Cannot use Socket Mode." + - label: "EC2 / Elastic Beanstalk" + description: "Always-on VM. Supports Socket Mode, good for Teams bots. ~$8/month for t3.micro." + - label: "ECS / Fargate" + description: "Containerized, production-grade. Auto-scaling, no server management. Good for high-traffic bots." + - label: "You Decide Everything" + description: "Use Lambda + API Gateway (recommended default) and skip remaining questions." +multiSelect: false +``` + +### Q2 — Infrastructure as Code +``` +question: "How do you want to define your infrastructure?" +header: "IaC" +options: + - label: "AWS SAM (Recommended)" + description: "YAML templates for Lambda + API Gateway. sam build && sam deploy — simple and well-documented." + - label: "AWS CDK" + description: "Define infrastructure in TypeScript. Full AWS resource control. More flexible but more setup." + - label: "Manual CLI" + description: "Step-by-step aws CLI commands. Learn exactly what resources are created." + - label: "You Decide Everything" + description: "Use AWS SAM (recommended default) and skip remaining questions." +multiSelect: false +``` + +### defaults table + +| Question | Default | +|---|---| +| Q1 | Lambda + API Gateway | +| Q2 | AWS SAM | + +## patterns + +### Slack bot on Lambda with SAM + +```yaml +# template.yaml (SAM template) +AWSTemplateFormatVersion: '2010-09-09' +Transform: AWS::Serverless-2016-10-31 +Description: Slack bot on Lambda + +Globals: + Function: + Timeout: 30 + Runtime: nodejs20.x + MemorySize: 256 + +Resources: + SlackBotFunction: + Type: AWS::Serverless::Function + Properties: + Handler: dist/lambda.handler + CodeUri: . + Events: + SlackEvents: + Type: HttpApi + Properties: + Path: /slack/events + Method: POST + Environment: + Variables: + SLACK_SECRET_NAME: bot/slack # reference, not the actual secret + Policies: + - SecretsManagerReadWrite + +Outputs: + SlackEndpoint: + Description: "URL for Slack Event Subscriptions" + Value: !Sub "https://${ServerlessHttpApi}.execute-api.${AWS::Region}.amazonaws.com/slack/events" +``` + +```bash +# Deploy the SAM stack +sam build +sam deploy --guided \ + --stack-name slack-bot \ + --capabilities CAPABILITY_IAM \ + --resolve-s3 + +# Output shows the API Gateway URL — use it as Slack Request URL +``` + +```typescript +// src/lambda.ts — Lambda handler wrapping Bolt +import { App, AwsLambdaReceiver } from "@slack/bolt"; + +const awsReceiver = new AwsLambdaReceiver({ + signingSecret: process.env.SLACK_SIGNING_SECRET!, +}); + +const app = new App({ + token: process.env.SLACK_BOT_TOKEN!, + receiver: awsReceiver, +}); + +app.message("hello", async ({ say }) => { + await say("Hi from Lambda!"); +}); + +export const handler = async (event: any, context: any, callback: any) => { + const handler = await awsReceiver.start(); + return handler(event, context, callback); +}; +``` + +### Slack bot on EC2 with Elastic Beanstalk + +```bash +# 1. Install EB CLI +pip install awsebcli + +# 2. Initialize the project +eb init slack-bot --platform "Node.js 20" --region us-east-1 + +# 3. Create the environment +eb create slack-bot-env --single --instance-types t3.micro + +# 4. Set environment variables +eb setenv \ + SLACK_BOT_TOKEN=xoxb-your-token \ + SLACK_SIGNING_SECRET=your-signing-secret \ + SLACK_APP_TOKEN=xapp-your-app-token \ + PORT=8080 + +# 5. Deploy +eb deploy + +# 6. Get the URL +eb status # shows CNAME: slack-bot-env.us-east-1.elasticbeanstalk.com + +# Configure Slack Request URL: +# https://slack-bot-env.us-east-1.elasticbeanstalk.com/slack/events +``` + +### Teams bot on AWS (Lambda + Azure Bot Service) + +```bash +# Step 1: Deploy to AWS (same as Slack bot SAM pattern, but different routes) +# In template.yaml, use Path: /api/messages instead of /slack/events + +# Step 2: Register in Azure (required for Teams) +az login +APP_ID=$(az ad app create --display-name "MyBot-AWS" --query appId -o tsv) +APP_SECRET=$(az ad app credential reset --id $APP_ID --query password -o tsv) +TENANT_ID=$(az account show --query tenantId -o tsv) + +az bot create \ + --resource-group rg-mybot \ + --name mybot-aws \ + --app-type SingleTenant \ + --appid $APP_ID \ + --tenant-id $TENANT_ID + +az bot msteams create --resource-group rg-mybot --name mybot-aws + +# Step 3: Set the messaging endpoint to your AWS URL +API_URL="https://abc123.execute-api.us-east-1.amazonaws.com/api/messages" +az bot update --resource-group rg-mybot --name mybot-aws --endpoint $API_URL + +# Step 4: Add Azure credentials to AWS Lambda environment +aws lambda update-function-configuration \ + --function-name MyTeamsBot \ + --environment "Variables={MicrosoftAppId=$APP_ID,MicrosoftAppPassword=$APP_SECRET,MicrosoftAppTenantId=$TENANT_ID}" +``` + +### Socket Mode on EC2 (long-running process) + +```bash +# Socket Mode requires a persistent WebSocket — use EC2 or ECS, not Lambda + +# 1. Launch an EC2 instance (Amazon Linux 2023, t3.micro) +aws ec2 run-instances \ + --image-id ami-0c02fb55956c7d316 \ + --instance-type t3.micro \ + --key-name my-key \ + --security-group-ids sg-xxx \ + --tag-specifications 'ResourceType=instance,Tags=[{Key=Name,Value=slack-bot}]' + +# 2. SSH in and install Node.js +ssh -i my-key.pem ec2-user@ +curl -fsSL https://rpm.nodesource.com/setup_20.x | sudo bash - +sudo yum install -y nodejs + +# 3. Clone, install, build +git clone https://github.com/your-org/your-bot.git +cd your-bot && npm install && npm run build + +# 4. Set environment variables +export SLACK_BOT_TOKEN=xoxb-your-token +export SLACK_APP_TOKEN=xapp-your-app-token +export SLACK_SIGNING_SECRET=your-signing-secret + +# 5. Run with PM2 for process management +npm install -g pm2 +pm2 start dist/index.js --name slack-bot +pm2 save +pm2 startup # auto-restart on reboot +``` + +## pitfalls + +- **Lambda cold starts causing Slack ack timeout.** Node.js Lambda cold starts take 1-5 seconds. If your handler does any work before calling `ack()`, you'll exceed the 3-second Slack deadline. Use provisioned concurrency, or ack immediately and process asynchronously. +- **Socket Mode on Lambda.** Socket Mode requires a persistent WebSocket connection. Lambda functions are ephemeral — they spin down after the request completes. Use EC2, Elastic Beanstalk, or ECS Fargate for Socket Mode. +- **Forgetting Azure Bot Service for Teams bots.** Even though your bot runs on AWS, Teams bots require an Azure Bot Service resource with the messaging endpoint pointing to your AWS URL. Without it, Teams cannot discover or route messages to your bot. +- **API Gateway default timeout.** API Gateway has a 29-second integration timeout. For most bot handlers this is fine, but long-running AI inference calls may exceed it. Use async invocation patterns for heavy processing. +- **Lambda function URL vs API Gateway.** Function URLs are simpler (no API Gateway needed) but lack WAF, throttling, and custom domain support. Use API Gateway for production bots that need rate limiting or custom domains. +- **Missing IAM permissions for Secrets Manager.** If your Lambda execution role doesn't include `secretsmanager:GetSecretValue`, the bot crashes when trying to fetch credentials. Add the policy to the SAM template or IAM role. +- **Elastic Beanstalk port mismatch.** EB expects your app to listen on port 8080 by default (configurable). If your bot hardcodes port 3000, the health check fails and EB marks the instance unhealthy. Always use `process.env.PORT`. + +## references + +- https://docs.aws.amazon.com/cli/latest/userguide/getting-started-install.html +- https://docs.aws.amazon.com/serverless-application-model/latest/developerguide/what-is-sam.html +- https://docs.aws.amazon.com/lambda/latest/dg/nodejs-handler.html +- https://docs.aws.amazon.com/lambda/latest/dg/provisioned-concurrency.html +- https://docs.aws.amazon.com/elasticbeanstalk/latest/dg/create_deploy_nodejs.html +- https://docs.aws.amazon.com/secretsmanager/latest/userguide/intro.html +- https://api.slack.com/authentication/basics +- https://slack.dev/bolt-js/deployments/aws-lambda +- https://learn.microsoft.com/azure/bot-service/bot-service-quickstart-registration + +## instructions + +This expert walks through deploying a bot to AWS from scratch — from installing the CLI to verifying a test message. Use it when a developer says "deploy my bot to AWS", "set up Lambda hosting", or "get my bot running on AWS". Covers Slack bots (Lambda or EC2), Teams bots (requires Azure Bot Service + AWS hosting), and Socket Mode considerations. + +Pair with: `../slack/runtime.bolt-foundations-ts.md` (Bolt app setup for receiver selection), `../slack/bolt-oauth-distribution-ts.md` (OAuth for multi-workspace Slack apps), `../security/secrets-ts.md` (secrets best practices), `../bridge/infra-compute-ts.md` (if comparing AWS compute options with Azure equivalents), `azure-bot-deploy-ts.md` (if also needing Azure Bot Service for Teams). + +## research + +Deep Research prompt: + +"Write a micro expert on deploying a Slack Bolt.js or Microsoft Teams bot to AWS. Cover: AWS CLI v2 installation, aws configure / aws sso login, IAM role creation for bot execution, Lambda + API Gateway deployment with SAM (template.yaml, sam build, sam deploy), AwsLambdaReceiver from @slack/bolt, EC2 deployment with PM2 for Socket Mode, Elastic Beanstalk for managed EC2, ECS Fargate for containerized bots, Secrets Manager for credential storage, CloudWatch alarms for error monitoring, provisioned concurrency for cold start mitigation, Teams-on-AWS pattern (Azure Bot Service pointing to AWS endpoint), and Slack app URL configuration. Provide 3-4 canonical deployment examples and 5-7 common pitfalls." diff --git a/plugins/microsoft-365-agents-toolkit/skills/teams-app-developer/experts/deploy/aws-cli-reference-ts.md b/plugins/microsoft-365-agents-toolkit/skills/teams-app-developer/experts/deploy/aws-cli-reference-ts.md new file mode 100644 index 0000000..d3fcaf2 --- /dev/null +++ b/plugins/microsoft-365-agents-toolkit/skills/teams-app-developer/experts/deploy/aws-cli-reference-ts.md @@ -0,0 +1,939 @@ +# aws-cli-reference-ts + +## purpose + +Comprehensive reference of all AWS CLI (`aws`) command groups a developer needs for creating, reading, updating, and deleting resources in a bot or AI agent project on AWS. Use as a lookup companion to `aws-bot-deploy-ts.md` (step-by-step deployment) — this file maps every relevant CLI surface so you know what commands exist. + +## rules + +1. **This is a reference, not a tutorial.** For step-by-step deployment walkthroughs, see `aws-bot-deploy-ts.md`. This file catalogs every `aws` command group relevant to bot/agent projects. +2. **Always authenticate first.** Every command below assumes you have run `aws configure` (or `aws sso login`) and verified with `aws sts get-caller-identity`. [docs.aws.amazon.com/cli/latest/userguide/cli-configure-quickstart.html](https://docs.aws.amazon.com/cli/latest/userguide/cli-configure-quickstart.html) +3. **Region matters.** Most commands operate in your configured default region. Override per-command with `--region `, or set globally with `export AWS_DEFAULT_REGION=us-east-1`. + +--- + +## 1. IAM (`aws iam`) — Identity & Access Management + +Every bot needs an execution role with least-privilege permissions. + +### Roles + +| Command | Purpose | +|---|---| +| `aws iam create-role --role-name --assume-role-policy-document file://trust.json` | Create execution role for Lambda/ECS/EC2 bot | +| `aws iam get-role --role-name ` | Read role details including ARN | +| `aws iam list-roles` | List all roles | +| `aws iam update-role --role-name --max-session-duration 7200` | Update role session duration | +| `aws iam update-assume-role-policy --role-name --policy-document file://trust.json` | Update who can assume the role | +| `aws iam delete-role --role-name ` | Delete a role (must detach policies first) | + +### Policies + +| Command | Purpose | +|---|---| +| `aws iam create-policy --policy-name --policy-document file://policy.json` | Create custom policy for bot permissions | +| `aws iam get-policy --policy-arn ` | Read policy metadata | +| `aws iam get-policy-version --policy-arn --version-id v1` | Read actual policy document | +| `aws iam list-policies --scope Local` | List custom policies | +| `aws iam create-policy-version --policy-arn --policy-document file://policy.json --set-as-default` | Update policy (creates new version) | +| `aws iam delete-policy --policy-arn ` | Delete policy | + +### Attach/Detach Policies to Roles + +| Command | Purpose | +|---|---| +| `aws iam attach-role-policy --role-name --policy-arn ` | Attach managed policy to role | +| `aws iam list-attached-role-policies --role-name ` | List policies on a role | +| `aws iam detach-role-policy --role-name --policy-arn ` | Remove policy from role | +| `aws iam put-role-policy --role-name --policy-name --policy-document file://policy.json` | Attach inline policy | +| `aws iam delete-role-policy --role-name --policy-name ` | Delete inline policy | + +### Instance Profiles (for EC2 bots) + +| Command | Purpose | +|---|---| +| `aws iam create-instance-profile --instance-profile-name ` | Create instance profile for EC2 | +| `aws iam add-role-to-instance-profile --instance-profile-name --role-name ` | Link role to instance profile | +| `aws iam remove-role-from-instance-profile --instance-profile-name --role-name ` | Unlink role | +| `aws iam delete-instance-profile --instance-profile-name ` | Delete instance profile | + +Reference: [docs.aws.amazon.com/cli/latest/reference/iam](https://docs.aws.amazon.com/cli/latest/reference/iam) + +--- + +## 2. Lambda (`aws lambda`) — Serverless Bot Hosting + +### Functions + +| Command | Purpose | +|---|---| +| `aws lambda create-function --function-name --runtime nodejs20.x --role --handler index.handler --zip-file fileb://function.zip` | Create bot function | +| `aws lambda get-function --function-name ` | Read function config and code location | +| `aws lambda get-function-configuration --function-name ` | Read runtime config only | +| `aws lambda list-functions` | List all functions | +| `aws lambda update-function-code --function-name --zip-file fileb://function.zip` | Deploy new bot code | +| `aws lambda update-function-code --function-name --image-uri ` | Deploy from container image | +| `aws lambda update-function-configuration --function-name --timeout 30 --memory-size 256 --environment "Variables={KEY=value}"` | Update runtime settings | +| `aws lambda delete-function --function-name ` | Delete function | + +### Invocation & Testing + +| Command | Purpose | +|---|---| +| `aws lambda invoke --function-name --payload file://event.json output.json` | Invoke synchronously (test) | +| `aws lambda invoke --function-name --invocation-type Event --payload file://event.json output.json` | Invoke async (fire-and-forget) | + +### Event Source Mappings (SQS trigger for async bot processing) + +| Command | Purpose | +|---|---| +| `aws lambda create-event-source-mapping --function-name --event-source-arn --batch-size 10` | Connect SQS queue to Lambda | +| `aws lambda list-event-source-mappings --function-name ` | List triggers | +| `aws lambda update-event-source-mapping --uuid --batch-size 5` | Update trigger | +| `aws lambda delete-event-source-mapping --uuid ` | Remove trigger | + +### Permissions (resource-based policy) + +| Command | Purpose | +|---|---| +| `aws lambda add-permission --function-name --statement-id apigateway --action lambda:InvokeFunction --principal apigateway.amazonaws.com --source-arn ` | Allow API Gateway to invoke | +| `aws lambda get-policy --function-name ` | Read resource policy | +| `aws lambda remove-permission --function-name --statement-id apigateway` | Revoke permission | + +### Aliases & Versions (deployment strategy) + +| Command | Purpose | +|---|---| +| `aws lambda publish-version --function-name ` | Publish immutable version | +| `aws lambda create-alias --function-name --name prod --function-version 3` | Create alias pointing to version | +| `aws lambda update-alias --function-name --name prod --function-version 4` | Shift alias to new version | +| `aws lambda delete-alias --function-name --name prod` | Delete alias | + +### Function URL (alternative to API Gateway) + +| Command | Purpose | +|---|---| +| `aws lambda create-function-url-config --function-name --auth-type NONE` | Create public HTTPS endpoint | +| `aws lambda get-function-url-config --function-name ` | Read URL config | +| `aws lambda update-function-url-config --function-name --auth-type AWS_IAM` | Update auth type | +| `aws lambda delete-function-url-config --function-name ` | Delete URL endpoint | + +### Layers + +| Command | Purpose | +|---|---| +| `aws lambda publish-layer-version --layer-name --zip-file fileb://layer.zip --compatible-runtimes nodejs20.x` | Publish shared dependency layer | +| `aws lambda list-layers` | List available layers | +| `aws lambda delete-layer-version --layer-name --version-number 1` | Delete layer version | + +Reference: [docs.aws.amazon.com/cli/latest/reference/lambda](https://docs.aws.amazon.com/cli/latest/reference/lambda) + +--- + +## 3. API Gateway — HTTP Endpoints for Bots + +### HTTP API (`aws apigatewayv2`) — Recommended for bot webhooks + +| Command | Purpose | +|---|---| +| `aws apigatewayv2 create-api --name --protocol-type HTTP` | Create HTTP API | +| `aws apigatewayv2 get-api --api-id ` | Read API details | +| `aws apigatewayv2 get-apis` | List APIs | +| `aws apigatewayv2 update-api --api-id --name ` | Update API | +| `aws apigatewayv2 delete-api --api-id ` | Delete API | + +### Integrations + +| Command | Purpose | +|---|---| +| `aws apigatewayv2 create-integration --api-id --integration-type AWS_PROXY --integration-uri --payload-format-version 2.0` | Connect Lambda backend | +| `aws apigatewayv2 get-integration --api-id --integration-id ` | Read integration | +| `aws apigatewayv2 update-integration --api-id --integration-id --timeout-in-millis 10000` | Update integration | +| `aws apigatewayv2 delete-integration --api-id --integration-id ` | Remove integration | + +### Routes + +| Command | Purpose | +|---|---| +| `aws apigatewayv2 create-route --api-id --route-key "POST /slack/events" --target integrations/` | Create route for Slack events | +| `aws apigatewayv2 get-routes --api-id ` | List routes | +| `aws apigatewayv2 update-route --api-id --route-id --route-key "POST /slack/interactions"` | Update route | +| `aws apigatewayv2 delete-route --api-id --route-id ` | Delete route | + +### Stages & Deployment + +| Command | Purpose | +|---|---| +| `aws apigatewayv2 create-stage --api-id --stage-name prod --auto-deploy` | Create stage with auto-deploy | +| `aws apigatewayv2 get-stages --api-id ` | List stages | +| `aws apigatewayv2 update-stage --api-id --stage-name prod --stage-variables env=production` | Update stage variables | +| `aws apigatewayv2 delete-stage --api-id --stage-name prod` | Delete stage | + +### Custom Domain + +| Command | Purpose | +|---|---| +| `aws apigatewayv2 create-domain-name --domain-name bot.example.com --domain-name-configurations CertificateArn=` | Map custom domain | +| `aws apigatewayv2 create-api-mapping --api-id --domain-name bot.example.com --stage prod` | Map domain to stage | +| `aws apigatewayv2 delete-domain-name --domain-name bot.example.com` | Remove custom domain | + +### REST API (`aws apigateway`) — When you need request validation, API keys, usage plans + +| Command | Purpose | +|---|---| +| `aws apigateway create-rest-api --name --endpoint-configuration types=REGIONAL` | Create REST API | +| `aws apigateway get-rest-api --rest-api-id ` | Read API | +| `aws apigateway get-rest-apis` | List REST APIs | +| `aws apigateway delete-rest-api --rest-api-id ` | Delete API | +| `aws apigateway get-resources --rest-api-id ` | List resources/paths | +| `aws apigateway create-resource --rest-api-id --parent-id --path-part slack` | Create path segment | +| `aws apigateway put-method --rest-api-id --resource-id --http-method POST --authorization-type NONE` | Create method | +| `aws apigateway put-integration --rest-api-id --resource-id --http-method POST --type AWS_PROXY --integration-http-method POST --uri ` | Connect to Lambda | +| `aws apigateway create-deployment --rest-api-id --stage-name prod` | Deploy changes | + +Reference: [docs.aws.amazon.com/cli/latest/reference/apigatewayv2](https://docs.aws.amazon.com/cli/latest/reference/apigatewayv2) + +--- + +## 4. EC2 (`aws ec2`) — VM Hosting for Socket Mode Bots + +### Instances + +| Command | Purpose | +|---|---| +| `aws ec2 run-instances --image-id --instance-type t3.micro --key-name --security-group-ids --subnet-id --iam-instance-profile Name= --tag-specifications "ResourceType=instance,Tags=[{Key=Name,Value=slack-bot}]"` | Launch bot instance | +| `aws ec2 describe-instances --filters "Name=tag:Name,Values=slack-bot"` | Find bot instances | +| `aws ec2 describe-instance-status --instance-ids ` | Check instance health | +| `aws ec2 start-instances --instance-ids ` | Start stopped instance | +| `aws ec2 stop-instances --instance-ids ` | Stop instance (preserve state) | +| `aws ec2 reboot-instances --instance-ids ` | Reboot instance | +| `aws ec2 terminate-instances --instance-ids ` | Delete instance permanently | + +### Key Pairs (SSH access) + +| Command | Purpose | +|---|---| +| `aws ec2 create-key-pair --key-name --query "KeyMaterial" --output text > key.pem` | Create SSH key pair | +| `aws ec2 describe-key-pairs` | List key pairs | +| `aws ec2 delete-key-pair --key-name ` | Delete key pair | + +### Security Groups (firewall) + +| Command | Purpose | +|---|---| +| `aws ec2 create-security-group --group-name bot-sg --description "Bot security group" --vpc-id ` | Create security group | +| `aws ec2 authorize-security-group-ingress --group-id --protocol tcp --port 443 --cidr 0.0.0.0/0` | Allow inbound HTTPS | +| `aws ec2 describe-security-groups --group-ids ` | Read rules | +| `aws ec2 revoke-security-group-ingress --group-id --protocol tcp --port 22 --cidr 0.0.0.0/0` | Remove inbound rule | +| `aws ec2 delete-security-group --group-id ` | Delete security group | + +### VPC Basics + +| Command | Purpose | +|---|---| +| `aws ec2 describe-vpcs` | List VPCs | +| `aws ec2 describe-subnets --filters "Name=vpc-id,Values="` | List subnets in VPC | + +### AMI (machine images) + +| Command | Purpose | +|---|---| +| `aws ec2 describe-images --owners amazon --filters "Name=name,Values=al2023-ami-*-x86_64"` | Find Amazon Linux AMI | +| `aws ec2 create-image --instance-id --name "bot-snapshot"` | Create AMI from running instance | + +Reference: [docs.aws.amazon.com/cli/latest/reference/ec2](https://docs.aws.amazon.com/cli/latest/reference/ec2) + +--- + +## 5. ECS (`aws ecs`) — Containerized Bot Hosting + +### Clusters + +| Command | Purpose | +|---|---| +| `aws ecs create-cluster --cluster-name --capacity-providers FARGATE --default-capacity-provider-strategy capacityProvider=FARGATE,weight=1` | Create Fargate cluster | +| `aws ecs describe-clusters --clusters ` | Read cluster details | +| `aws ecs list-clusters` | List clusters | +| `aws ecs delete-cluster --cluster ` | Delete cluster (must be empty) | + +### Task Definitions (container blueprint) + +| Command | Purpose | +|---|---| +| `aws ecs register-task-definition --cli-input-json file://task-def.json` | Create/update task definition | +| `aws ecs describe-task-definition --task-definition ` | Read latest task def | +| `aws ecs describe-task-definition --task-definition :` | Read specific revision | +| `aws ecs list-task-definitions --family-prefix ` | List revisions | +| `aws ecs deregister-task-definition --task-definition :` | Deactivate revision | + +### Services (long-running bot) + +| Command | Purpose | +|---|---| +| `aws ecs create-service --cluster --service-name --task-definition --desired-count 1 --launch-type FARGATE --network-configuration "awsvpcConfiguration={subnets=[],securityGroups=[],assignPublicIp=ENABLED}"` | Create service | +| `aws ecs describe-services --cluster --services ` | Read service status | +| `aws ecs list-services --cluster ` | List services | +| `aws ecs update-service --cluster --service --desired-count 2` | Scale service | +| `aws ecs update-service --cluster --service --task-definition : --force-new-deployment` | Deploy new version | +| `aws ecs delete-service --cluster --service --force` | Delete service | + +### Tasks (individual containers) + +| Command | Purpose | +|---|---| +| `aws ecs run-task --cluster --task-definition --launch-type FARGATE --network-configuration "awsvpcConfiguration={...}"` | Run one-off task | +| `aws ecs list-tasks --cluster --service-name ` | List running tasks | +| `aws ecs describe-tasks --cluster --tasks ` | Read task details | +| `aws ecs stop-task --cluster --task --reason "manual stop"` | Stop a running task | +| `aws ecs execute-command --cluster --task --container --interactive --command "/bin/sh"` | Exec into running container | + +Reference: [docs.aws.amazon.com/cli/latest/reference/ecs](https://docs.aws.amazon.com/cli/latest/reference/ecs) + +--- + +## 6. Elastic Beanstalk (`aws elasticbeanstalk`) — Managed Hosting + +| Command | Purpose | +|---|---| +| `aws elasticbeanstalk create-application --application-name ` | Create application | +| `aws elasticbeanstalk describe-applications --application-names ` | Read application | +| `aws elasticbeanstalk update-application --application-name --description "Slack bot"` | Update application | +| `aws elasticbeanstalk delete-application --application-name --terminate-env-by-force` | Delete application | +| `aws elasticbeanstalk create-application-version --application-name --version-label v1 --source-bundle S3Bucket=,S3Key=` | Upload version | +| `aws elasticbeanstalk create-environment --application-name --environment-name prod --solution-stack-name "64bit Amazon Linux 2023 v6.1.0 running Node.js 20" --option-settings file://options.json` | Create environment | +| `aws elasticbeanstalk describe-environments --application-name ` | Read environment status | +| `aws elasticbeanstalk update-environment --environment-name --version-label v2` | Deploy new version | +| `aws elasticbeanstalk terminate-environment --environment-name ` | Delete environment | +| `aws elasticbeanstalk list-platform-versions --filters "Type=PlatformName,Operator=contains,Values=Node.js"` | Find supported platforms | + +Reference: [docs.aws.amazon.com/cli/latest/reference/elasticbeanstalk](https://docs.aws.amazon.com/cli/latest/reference/elasticbeanstalk) + +--- + +## 7. App Runner (`aws apprunner`) — Simplified Container Hosting + +| Command | Purpose | +|---|---| +| `aws apprunner create-service --service-name --source-configuration file://source-config.json` | Create service from ECR image or GitHub | +| `aws apprunner describe-service --service-arn ` | Read service details and URL | +| `aws apprunner list-services` | List services | +| `aws apprunner update-service --service-arn --source-configuration file://source-config.json` | Update source/config | +| `aws apprunner delete-service --service-arn ` | Delete service | +| `aws apprunner start-deployment --service-arn ` | Trigger manual deployment | +| `aws apprunner pause-service --service-arn ` | Pause (stop billing for compute) | +| `aws apprunner resume-service --service-arn ` | Resume paused service | +| `aws apprunner associate-custom-domain --service-arn --domain-name bot.example.com` | Map custom domain | +| `aws apprunner disassociate-custom-domain --service-arn --domain-name bot.example.com` | Remove custom domain | + +Reference: [docs.aws.amazon.com/cli/latest/reference/apprunner](https://docs.aws.amazon.com/cli/latest/reference/apprunner) + +--- + +## 8. Secrets Manager (`aws secretsmanager`) — Bot Credentials + +| Command | Purpose | +|---|---| +| `aws secretsmanager create-secret --name bot/slack --secret-string '{"SLACK_BOT_TOKEN":"xoxb-...","SLACK_SIGNING_SECRET":"..."}'` | Store bot credentials | +| `aws secretsmanager get-secret-value --secret-id bot/slack` | Read secret value | +| `aws secretsmanager describe-secret --secret-id bot/slack` | Read metadata (no value) | +| `aws secretsmanager list-secrets --filters Key=name,Values=bot/` | List secrets | +| `aws secretsmanager update-secret --secret-id bot/slack --secret-string '{"SLACK_BOT_TOKEN":"xoxb-new"}'` | Update secret value | +| `aws secretsmanager rotate-secret --secret-id bot/slack --rotation-lambda-arn ` | Trigger rotation | +| `aws secretsmanager delete-secret --secret-id bot/slack --recovery-window-in-days 7` | Soft delete (recoverable) | +| `aws secretsmanager delete-secret --secret-id bot/slack --force-delete-without-recovery` | Hard delete (immediate) | +| `aws secretsmanager restore-secret --secret-id bot/slack` | Recover soft-deleted secret | + +Reference: [docs.aws.amazon.com/cli/latest/reference/secretsmanager](https://docs.aws.amazon.com/cli/latest/reference/secretsmanager) + +--- + +## 9. SSM Parameter Store (`aws ssm`) — Configuration & Secrets + +| Command | Purpose | +|---|---| +| `aws ssm put-parameter --name /bot/config/log-level --value "info" --type String` | Create string parameter | +| `aws ssm put-parameter --name /bot/secrets/api-key --value "sk-..." --type SecureString` | Create encrypted parameter | +| `aws ssm get-parameter --name /bot/config/log-level` | Read parameter | +| `aws ssm get-parameter --name /bot/secrets/api-key --with-decryption` | Read encrypted parameter | +| `aws ssm get-parameters-by-path --path /bot/ --recursive --with-decryption` | Read all params under path | +| `aws ssm describe-parameters --parameter-filters "Key=Name,Option=BeginsWith,Values=/bot/"` | List parameters (metadata only) | +| `aws ssm put-parameter --name /bot/config/log-level --value "debug" --type String --overwrite` | Update parameter | +| `aws ssm delete-parameter --name /bot/config/log-level` | Delete parameter | +| `aws ssm delete-parameters --names /bot/config/log-level /bot/config/timeout` | Batch delete | + +Reference: [docs.aws.amazon.com/cli/latest/reference/ssm](https://docs.aws.amazon.com/cli/latest/reference/ssm) + +--- + +## 10. CloudWatch & Logs (`aws cloudwatch`, `aws logs`) — Monitoring + +### CloudWatch Metrics & Alarms + +| Command | Purpose | +|---|---| +| `aws cloudwatch put-metric-alarm --alarm-name bot-errors --metric-name Errors --namespace AWS/Lambda --statistic Sum --period 300 --threshold 5 --comparison-operator GreaterThanThreshold --evaluation-periods 1 --alarm-actions --dimensions Name=FunctionName,Value=` | Create error alarm | +| `aws cloudwatch describe-alarms --alarm-names bot-errors` | Read alarm config | +| `aws cloudwatch list-metrics --namespace AWS/Lambda --dimensions Name=FunctionName,Value=` | List available metrics | +| `aws cloudwatch get-metric-statistics --namespace AWS/Lambda --metric-name Duration --dimensions Name=FunctionName,Value= --start-time