@cuylabs/agent-channel-m365
v5.2.0
Published
Microsoft 365 Activity Protocol adapter for @cuylabs/agent-core turn sources
Maintainers
Readme
@cuylabs/agent-channel-m365
Microsoft 365 channel adapter for @cuylabs/agent-core. It bridges agent-core chat turns into the Microsoft 365 Agents SDK transport layer for Teams and other Activity Protocol channels, with a clean path to Copilot-hosted scenarios that only need message turns.
How it works
This package bridges two systems that operate at different layers of the stack:
┌───────────────────────────────────────────────────┐
│ Microsoft 365 Channels │
│ Teams · Webchat · compatible Activity clients │
└─────────────────────┬─────────────────────────────┘
│ Activity Protocol (HTTP + JWT)
┌─────────────────────▼─────────────────────────────┐
│ @microsoft/agents-hosting │
│ CloudAdapter → TurnContext → StreamingResponse │
└─────────────────────┬─────────────────────────────┘
│ handler(context)
┌─────────────────────▼─────────────────────────────┐
│ @cuylabs/agent-channel-m365 ← this package │
│ Maps Activity → agent.chat() → AgentEvent stream │
│ → StreamingResponse.queueTextChunk() │
└─────────────────────┬─────────────────────────────┘
│ agent.chat(sessionId, text)
┌─────────────────────▼─────────────────────────────┐
│ @cuylabs/agent-core │
│ LLM inference · tools · sessions · multi-agent │
└───────────────────────────────────────────────────┘The M365 SDK handles transport (channel auth, Activity protocol, JWT validation). Your agent-core handles AI orchestration (inference, tools, sessions). This package is the bridge between them.
Support Matrix
| Capability | Status |
| ----------------------------------------------------------- | ---------------------------------------------------------------- |
| Teams / Web Chat message turns | Supported |
| Streaming text responses | Supported through StreamingResponse |
| Conversation update / welcome messages | Supported |
| Custom invoke handling | Supported through onInvoke / sendM365Invoke |
| Event, reaction, message update/delete hooks | Supported |
| Copilot-hosted plain message turns | Supported when the host calls the bot over the Activity protocol |
| Adaptive card workflows / task modules / message extensions | Host-specific, app code required |
| In-channel approval and human-input resolution | Not built-in yet; the adapter fails fast with a clear message |
| Full Security Copilot plugin surface | Out of scope for this transport adapter |
Install
pnpm add @cuylabs/agent-channel-m365 @microsoft/agents-hosting expressQuick start
import { createAgent } from "@cuylabs/agent-core";
import { anthropic } from "@ai-sdk/anthropic";
import { mountM365Agent } from "@cuylabs/agent-channel-m365";
const agent = createAgent({
model: anthropic("claude-sonnet-4-20250514"),
tools: [
/* your tools */
],
});
const { server } = await mountM365Agent(agent, {
welcomeMessage: "Hello! I'm your AI assistant.",
feedbackLoop: true,
});
// → M365 Agent listening on port 3978Set environment variables for M365 auth:
MicrosoftAppId=<your-app-id>
MicrosoftAppPassword=<your-app-secret>
MicrosoftAppTenantId=<your-tenant-id>Low-level usage
For full control over the Express app and CloudAdapter:
import express from "express";
import {
CloudAdapter,
authorizeJWT,
getAuthConfigWithDefaults,
} from "@microsoft/agents-hosting";
import { createM365ChannelAdapter } from "@cuylabs/agent-channel-m365";
const authConfig = getAuthConfigWithDefaults();
const cloudAdapter = new CloudAdapter(authConfig);
const m365 = createM365ChannelAdapter({ agent });
const app = express();
app.post("/api/messages", (req, res) =>
cloudAdapter.process(req, res, m365.handler),
);
app.listen(3978);mountM365Agent() mounts route-scoped JSON parsing and JWT auth only on the configured message route. It no longer installs global middleware on the whole app.
Streaming
The adapter streams tokens to channels that support it:
| Channel | Streaming | Delay | | --------------------------------------------- | --------- | --------------------- | | Teams | Yes | 1000ms between chunks | | Webchat / DirectLine | Yes | 500ms between chunks | | Copilot-hosted non-streaming Activity clients | Buffered | Single response | | Other channels | Buffered | Single response |
Streaming is handled automatically by the M365 StreamingResponse.
If you need richer response metadata, return it from prepareTurn():
prepareTurn: () => ({
response: {
citations: [{ title: "Runbook", url: "https://example.com/runbook" }],
attachments: [cardAttachment],
sensitivityLabel: { labelId: "label-1" },
finalMessage: { type: "message", text: "Done." },
},
});Configuration
createM365ChannelAdapter({
agent,
// Session mapping
sessionStrategy: "conversation-id", // or "custom"
resolveSessionId: (convId, channelId) => `${channelId}:${convId}`,
// UI features
showReasoning: false, // Show "Thinking..." in Teams
showToolUsage: true, // Show "Using tool: X..." in Teams
generatedByAILabel: true, // "Generated by AI" label
feedbackLoop: false, // Thumbs up/down in Teams
// Formatting
formatToolUpdate: (name) => `Running ${name}...`,
formatToolError: (name, err) => `${name} failed`,
formatReasoningUpdate: () => "Analyzing...",
formatInitialUpdate: () => "Starting...",
formatStreamError: () =>
"I encountered an error while processing your request. Please try again.",
onStreamEnd: (event, request) => {
console.log("stream ended", {
conversationId: request.user.conversationId,
result: event.result,
responseLength: event.response.length,
error: event.error?.message,
});
},
// Lifecycle
welcomeMessage: "Hi there!",
timeout: 120_000,
a365Observability: {
agentId: "agent-456",
agentDescription: "Organizes email and calendar work",
agentVersion: "1.0.0",
},
resolveMessage: (context) =>
typeof context.activity.value === "string"
? context.activity.value
: context.activity.text,
onInvoke: async (context) => {
await sendM365Invoke(context, { ok: true });
},
onEvent: async (context) => {
/* host event */
},
onMessageReaction: async (context) => {
/* reaction */
},
onMessageUpdate: async (context) => {
/* edit */
},
onMessageDelete: async (context) => {
/* delete */
},
onSessionEnd: (sessionId) => {
/* cleanup */
},
onError: (error, convId) => {
/* logging */
},
});Agent 365 Observability
The M365 channel can wrap message turns with Agent 365 observability baggage
before it calls agent.chat().
Startup still belongs in your host process:
import { createAgent } from "@cuylabs/agent-core";
import {
createA365TracingConfig,
initA365Observability,
} from "@cuylabs/agent-a365-observability";
import { createM365ChannelAdapter } from "@cuylabs/agent-channel-m365";
const observability = await initA365Observability({
serviceName: "email-agent-service",
serviceVersion: "1.0.0",
configuration: {
exporterEnabled: process.env.ENABLE_A365_OBSERVABILITY_EXPORTER === "true",
},
tokenResolver: async (agentId, tenantId) =>
getObservabilityToken(agentId, tenantId),
});
const agent = createAgent({
name: "email-assistant",
model,
tracing: createA365TracingConfig({
agentId: "agent-456",
agentDescription: "Organizes email and calendar work",
agentVersion: "1.0.0",
}),
});
const m365 = createM365ChannelAdapter({
agent,
a365Observability: {
agentId: "agent-456",
agentDescription: "Organizes email and calendar work",
agentVersion: "1.0.0",
},
});During each message turn, the adapter derives tenant, conversation, channel, and
user values from the TurnContext activity and passes them to
runWithA365TurnContext(). Live export still requires Microsoft Agent 365
Frontier preview access and a real token resolver.
If you do not provide onInvoke, the adapter now sends an explicit
501 Not Implemented invoke response instead of relying on implicit
CloudAdapter fallback behavior.
Turn Context
The adapter can now enrich each turn with structured context instead of forcing everything into the system prompt.
import {
createM365ChannelAdapter,
currentM365TurnContext,
sendM365Invoke,
} from "@cuylabs/agent-channel-m365";
const m365 = createM365ChannelAdapter({
agent,
prepareTurn: ({ user, turnContext }) => ({
system: `Help ${user.userName}.`,
scopeName: "teams-message",
scopeAttributes: {
channelId: user.channelId,
tenantId: user.tenantId,
},
context: {
aadObjectId: turnContext.activity.from?.aadObjectId,
},
}),
});
// Inside a tool or middleware that imports this package:
const ctx = currentM365TurnContext();Invoke Handling
Use sendM365Invoke() when your host needs an explicit invoke response:
import {
createM365ChannelAdapter,
sendM365Invoke,
} from "@cuylabs/agent-channel-m365";
const m365 = createM365ChannelAdapter({
agent,
onInvoke: async (context) => {
await sendM365Invoke(context, { ok: true });
},
});You can also pass a generic source instead of a direct Agent, as long as it implements AgentTurnSource from @cuylabs/agent-core:
interface AgentTurnSource {
chat(
sessionId: string,
message: string,
options?: {
abort?: AbortSignal;
system?: string;
},
): AsyncGenerator<AgentEvent>;
}Telemetry (OpenTelemetry)
The Microsoft 365 Agents SDK 1.5+ ships @microsoft/agents-telemetry, which
emits OpenTelemetry spans for transport work that lives behind this channel:
agents.adapter.process— every inbound activityagents.adapter.send_activities/update_activity/delete_activityagents.adapter.continue_conversation— proactive sends (see below)agents.connector.*— REST calls to the Bot Framework connectoragents.storage.*— bot state storage operationsagents.authentication.*/agents.authorization.azure_bot_obo_token— token acquisition and on-behalf-of flowsagents.dialogs.*— dialog stack lifecycleagents.app.*—AgentApplicationlifecycle (only emitted if you use it)
This is intentionally transport-only telemetry. It is not LLM telemetry —
LLM call spans come from @cuylabs/agent-core and the AI SDK provider you wire
up. Together they give you end-to-end traces from inbound activity through
inference and back out to the channel.
@microsoft/agents-hosting 1.5+ already depends on
@microsoft/agents-telemetry; the opt-in part is installing the OpenTelemetry
API packages and configuring an SDK/exporter. To enable full tracing and log
integration:
pnpm add @opentelemetry/api @opentelemetry/api-logsThen configure an OpenTelemetry SDK with whatever exporter you use — the
existing @cuylabs/agent-a365-observability startup already initializes a
NodeSDK, so the SDK transport spans are picked up automatically once the OTel
API packages are on the install path. To export to Application Insights /
Azure Monitor, layer @cuylabs/agent-azure-monitor (or the underlying
@azure/monitor-opentelemetry-exporter) on the same NodeSDK.
Disable specific span categories without touching code:
AGENTS_TELEMETRY_DISABLED_SPAN_CATEGORIES="STORAGE,DIALOGS"See the upstream package README for the full list of categories and exporter recipes.
Proactive Messaging
@microsoft/agents-hosting 1.5+ ships a proactive messaging stack
(Conversation, ConversationBuilder, ConversationReferenceBuilder,
CreateConversationOptions, and a high-level Proactive class). The
high-level class is bound to AgentApplication, which this package
intentionally does not adopt.
@cuylabs/agent-channel-m365/proactive exposes the underlying primitives plus
a thin continueM365Conversation wrapper around CloudAdapter.continueConversation,
so you can capture a reference during a live turn and replay it later without
pulling in AgentApplication:
import {
captureM365ConversationReference,
continueM365Conversation,
restoreM365ConversationReference,
} from "@cuylabs/agent-channel-m365/proactive";
// During a live turn — store this in the persistence backend of your choice
const conversation = captureM365ConversationReference(turnContext);
const conversationId = conversation.reference.conversation!.id!;
await proactiveStore.put(conversationId, conversation.toJson());
// Later, anywhere with access to the same CloudAdapter:
const stored = await proactiveStore.get(conversationId);
const restored = restoreM365ConversationReference(stored);
await continueM365Conversation(cloudAdapter, restored, async (ctx) => {
await ctx.sendActivity("Your async task finished.");
});Spans for replay are emitted under agents.adapter.continue_conversation once
OpenTelemetry is configured.
Configuration Notes
azureRegion(Agents SDK 1.5+) —AuthConfigurationnow accepts an optionalazureRegionfield that flows into the MSAL token provider for sovereign-cloud / regional auth. Set it through your normalAuthConfigurationconstruction (env-driven viagetAuthConfigWithDefaults).- OAuth env variable rename (Agents SDK 1.5+) —
AzureBotAuthorizationOptionsproperties were renamed to align with the .NET / Python SDKs. Legacy env vars still resolve via deprecated aliases, but new deployments should adopt the new names; consult the upstream #872 for the mapping. adapter.processheaders fix (Agents SDK 1.5+) — fixes a sporadic "Cannot set headers after they are sent" crash when a customlogiccallback responded beforeCloudAdapterdid.processWithAdapterin./express-compatbenefits automatically once the peer dep is bumped.
Docs
The package README is the quick-start view. For the deeper guides, use the docs set:
- docs/README.md for the documentation index
- docs/architecture.md for the bridge boundary, request flow, streaming, and session model
- docs/setup-and-deployment.md for Azure setup, Bot registration, and Teams app packaging
- docs/hosted-surfaces.md for Copilot-hosted and other host-specific boundaries
- docs/testing-and-troubleshooting.md for local validation and common failures
