diff --git a/docs/generated/postgres-schema/public.agent_chat_subscriptions.md b/docs/generated/postgres-schema/public.agent_chat_subscriptions.md
index b7222da650a..caadb5ce62c 100644
--- a/docs/generated/postgres-schema/public.agent_chat_subscriptions.md
+++ b/docs/generated/postgres-schema/public.agent_chat_subscriptions.md
@@ -15,7 +15,7 @@
| Name | Type | Definition |
| ---- | ---- | ---------- |
-| CHK_agent_chat_subscriptions_integrationType | CHECK | CHECK ((("integrationType")::text = ANY ((ARRAY['telegram'::character varying, 'slack'::character varying, 'linear'::character varying, 'discord'::character varying])::text[]))) |
+| CHK_agent_chat_subscriptions_integrationType | CHECK | CHECK ((("integrationType")::text = ANY ((ARRAY['telegram'::character varying, 'slack'::character varying, 'linear'::character varying, 'discord'::character varying, 'teams'::character varying])::text[]))) |
| FK_e79153bd179c011e779d5016796 | FOREIGN KEY | FOREIGN KEY ("agentId") REFERENCES agents(id) ON DELETE CASCADE |
| PK_76598cf91038bee1f3ac94c94bc | PRIMARY KEY | PRIMARY KEY ("agentId", "integrationType", "credentialId", "threadId") |
| agent_chat_subscriptions_agentId_not_null | n | NOT NULL "agentId" |
diff --git a/docs/generated/sqlite-schema/agent_chat_subscriptions.md b/docs/generated/sqlite-schema/agent_chat_subscriptions.md
index 2e7d9739c38..e54e2c0ec21 100644
--- a/docs/generated/sqlite-schema/agent_chat_subscriptions.md
+++ b/docs/generated/sqlite-schema/agent_chat_subscriptions.md
@@ -6,7 +6,7 @@
Table Definition
```sql
-CREATE TABLE "agent_chat_subscriptions" ("agentId" varchar(36) NOT NULL, "integrationType" varchar(64) NOT NULL, "credentialId" varchar(255) NOT NULL, "threadId" varchar(255) NOT NULL, "createdAt" datetime(3) NOT NULL DEFAULT (STRFTIME('%Y-%m-%d %H:%M:%f', 'NOW')), "updatedAt" datetime(3) NOT NULL DEFAULT (STRFTIME('%Y-%m-%d %H:%M:%f', 'NOW')), CONSTRAINT "CHK_agent_chat_subscriptions_integrationType" CHECK ("integrationType" IN ('telegram', 'slack', 'linear', 'discord')), CONSTRAINT "FK_e79153bd179c011e779d5016796" FOREIGN KEY ("agentId") REFERENCES "agents" ("id") ON DELETE CASCADE ON UPDATE NO ACTION, PRIMARY KEY ("agentId", "integrationType", "credentialId", "threadId"))
+CREATE TABLE "agent_chat_subscriptions" ("agentId" varchar(36) NOT NULL, "integrationType" varchar(64) NOT NULL, "credentialId" varchar(255) NOT NULL, "threadId" varchar(255) NOT NULL, "createdAt" datetime(3) NOT NULL DEFAULT (STRFTIME('%Y-%m-%d %H:%M:%f', 'NOW')), "updatedAt" datetime(3) NOT NULL DEFAULT (STRFTIME('%Y-%m-%d %H:%M:%f', 'NOW')), CONSTRAINT "CHK_agent_chat_subscriptions_integrationType" CHECK ("integrationType" IN ('telegram', 'slack', 'linear', 'discord', 'teams')), CONSTRAINT "FK_e79153bd179c011e779d5016796" FOREIGN KEY ("agentId") REFERENCES "agents" ("id") ON DELETE CASCADE ON UPDATE NO ACTION, PRIMARY KEY ("agentId", "integrationType", "credentialId", "threadId"))
```
@@ -26,7 +26,7 @@ CREATE TABLE "agent_chat_subscriptions" ("agentId" varchar(36) NOT NULL, "integr
| Name | Type | Definition |
| ---- | ---- | ---------- |
-| - | CHECK | CHECK ("integrationType" IN ('telegram', 'slack', 'linear', 'discord')) |
+| - | CHECK | CHECK ("integrationType" IN ('telegram', 'slack', 'linear', 'discord', 'teams')) |
| - (Foreign key ID: 0) | FOREIGN KEY | FOREIGN KEY (agentId) REFERENCES agents (id) ON UPDATE NO ACTION ON DELETE CASCADE MATCH NONE |
| agentId | PRIMARY KEY | PRIMARY KEY (agentId) |
| credentialId | PRIMARY KEY | PRIMARY KEY (credentialId) |
diff --git a/packages/@n8n/api-types/src/agents/__tests__/agent-integration.schema.test.ts b/packages/@n8n/api-types/src/agents/__tests__/agent-integration.schema.test.ts
index 9b89c367417..6d5ffee2035 100644
--- a/packages/@n8n/api-types/src/agents/__tests__/agent-integration.schema.test.ts
+++ b/packages/@n8n/api-types/src/agents/__tests__/agent-integration.schema.test.ts
@@ -10,6 +10,31 @@ describe('AgentIntegrationSchema', () => {
expect(result.success).toBe(true);
});
+ it.each(['discord', 'linear', 'teams'])(
+ 'accepts a %s integration with or without a session idle timeout',
+ (type) => {
+ expect(AgentIntegrationSchema.safeParse({ type, credentialId: 'cred-123' }).success).toBe(
+ true,
+ );
+ expect(
+ AgentIntegrationSchema.safeParse({
+ type,
+ credentialId: 'cred-123',
+ settings: { sessionIdleTimeoutMinutes: 30 },
+ }).success,
+ ).toBe(true);
+ },
+ );
+
+ it('rejects Teams settings it does not define', () => {
+ const result = AgentIntegrationSchema.safeParse({
+ type: 'teams',
+ credentialId: 'cred-123',
+ settings: { accessMode: 'public' },
+ });
+ expect(result.success).toBe(false);
+ });
+
it('accepts an existing Slack integration without messaging settings', () => {
const result = AgentIntegrationSchema.safeParse({
type: 'slack',
diff --git a/packages/@n8n/api-types/src/agents/agent-integration.schema.ts b/packages/@n8n/api-types/src/agents/agent-integration.schema.ts
index 8da7a1f187b..cff4244844a 100644
--- a/packages/@n8n/api-types/src/agents/agent-integration.schema.ts
+++ b/packages/@n8n/api-types/src/agents/agent-integration.schema.ts
@@ -104,6 +104,9 @@ export type AgentDiscordIntegrationSettings = z.infer;
+export const AgentTeamsSettingsSchema = AgentSessionOnlySettingsSchema;
+export type AgentTeamsIntegrationSettings = z.infer;
+
export const AgentIntegrationSettingsSchema = z.union([
AgentTelegramSettingsSchema,
AgentSlackSettingsSchema,
@@ -128,6 +131,9 @@ const credentialIntegrations = [
createCredIntegrationSchema('discord', AgentDiscordSettingsSchema).extend({
settings: AgentDiscordSettingsSchema.optional(),
}),
+ createCredIntegrationSchema('teams', AgentTeamsSettingsSchema).extend({
+ settings: AgentTeamsSettingsSchema.optional(),
+ }),
] as const;
const draftCredentialIntegrations = [
@@ -143,6 +149,9 @@ const draftCredentialIntegrations = [
createDraftCredIntegrationSchema('discord', AgentDiscordSettingsSchema).extend({
settings: AgentDiscordSettingsSchema.optional(),
}),
+ createDraftCredIntegrationSchema('teams', AgentTeamsSettingsSchema).extend({
+ settings: AgentTeamsSettingsSchema.optional(),
+ }),
] as const;
export const AgentIntegrationSchema = z.discriminatedUnion('type', credentialIntegrations);
diff --git a/packages/@n8n/api-types/src/agents/dto.ts b/packages/@n8n/api-types/src/agents/dto.ts
index 2668207bfe9..b9cf4b60e14 100644
--- a/packages/@n8n/api-types/src/agents/dto.ts
+++ b/packages/@n8n/api-types/src/agents/dto.ts
@@ -44,6 +44,7 @@ export const AGENT_SESSION_ORIGINS = [
'telegram',
'linear',
'discord',
+ 'teams',
] as const;
export type AgentSessionStatus = (typeof AGENT_SESSION_STATUSES)[number];
diff --git a/packages/@n8n/db/src/migrations/common/1790231058977-AllowTeamsAgentChatSubscriptions.ts b/packages/@n8n/db/src/migrations/common/1790231058977-AllowTeamsAgentChatSubscriptions.ts
new file mode 100644
index 00000000000..ecae27b239e
--- /dev/null
+++ b/packages/@n8n/db/src/migrations/common/1790231058977-AllowTeamsAgentChatSubscriptions.ts
@@ -0,0 +1,21 @@
+import type { MigrationContext, ReversibleMigration } from '../migration-types';
+
+const TABLE = 'agent_chat_subscriptions';
+const COLUMN = 'integrationType';
+const VALUES_BEFORE = ['telegram', 'slack', 'linear', 'discord'];
+const VALUES_AFTER = [...VALUES_BEFORE, 'teams'];
+
+export class AllowTeamsAgentChatSubscriptions1790231058977 implements ReversibleMigration {
+ async up({ schemaBuilder }: MigrationContext) {
+ await schemaBuilder.dropEnumCheck(TABLE, COLUMN, { recreatesOnSqlite: true });
+ await schemaBuilder.addEnumCheck(TABLE, COLUMN, VALUES_AFTER, { recreatesOnSqlite: true });
+ }
+
+ async down({ escape, runQuery, schemaBuilder }: MigrationContext) {
+ await runQuery(
+ `DELETE FROM ${escape.tableName(TABLE)} WHERE ${escape.columnName(COLUMN)} = 'teams'`,
+ );
+ await schemaBuilder.dropEnumCheck(TABLE, COLUMN, { recreatesOnSqlite: true });
+ await schemaBuilder.addEnumCheck(TABLE, COLUMN, VALUES_BEFORE, { recreatesOnSqlite: true });
+ }
+}
diff --git a/packages/cli/package.json b/packages/cli/package.json
index 35c8edd357b..eb4208c450e 100644
--- a/packages/cli/package.json
+++ b/packages/cli/package.json
@@ -140,6 +140,7 @@
"@chat-adapter/linear": "catalog:",
"@chat-adapter/slack": "catalog:",
"@chat-adapter/state-memory": "catalog:",
+ "@chat-adapter/teams": "catalog:",
"@chat-adapter/telegram": "catalog:",
"@google-cloud/secret-manager": "5.6.0",
"@grpc/grpc-js": "^1.14.3",
diff --git a/packages/cli/src/modules/agents/agents.module.ts b/packages/cli/src/modules/agents/agents.module.ts
index 64f6b1c1c24..7c0c413f5c7 100644
--- a/packages/cli/src/modules/agents/agents.module.ts
+++ b/packages/cli/src/modules/agents/agents.module.ts
@@ -87,12 +87,14 @@ export class AgentsModule implements ModuleInterface {
);
const { LinearIntegration } = await import('./integrations/platforms/linear-integration.js');
const { DiscordIntegration } = await import('./integrations/platforms/discord-integration.js');
+ const { TeamsIntegration } = await import('./integrations/platforms/teams-integration.js');
const { N8nChatIntegration } = await import('./integrations/platforms/n8n-chat-integration.js');
const registry = Container.get(ChatIntegrationRegistry);
registry.register(Container.get(SlackIntegration));
registry.register(Container.get(TelegramIntegration));
registry.register(Container.get(LinearIntegration));
registry.register(Container.get(DiscordIntegration));
+ registry.register(Container.get(TeamsIntegration));
registry.register(Container.get(N8nChatIntegration));
// Resume Chat and Task services on startup so this main runs what its
diff --git a/packages/cli/src/modules/agents/integrations/CHANNEL_INTEGRATION_TESTS.md b/packages/cli/src/modules/agents/integrations/CHANNEL_INTEGRATION_TESTS.md
index 77106fcd568..3f4d8c74ee3 100644
--- a/packages/cli/src/modules/agents/integrations/CHANNEL_INTEGRATION_TESTS.md
+++ b/packages/cli/src/modules/agents/integrations/CHANNEL_INTEGRATION_TESTS.md
@@ -34,6 +34,7 @@ Each platform adapter uses a different HTTP client, so the interception mechanis
| Telegram | native `fetch` | replace `globalThis.fetch` | `installFetchStub` (replay-test-helpers) |
| Slack | `@slack/web-api` (axios) | `nock` at the HTTP layer | inline in slack `replay-test-context` |
| Linear | `@linear/sdk` (GraphQL over fetch) | replace `globalThis.fetch` | `installFetchStub` |
+| Teams | `@microsoft/teams.*` (axios) + `jwks-rsa` (node `https`) | `nock` at the HTTP layer | inline in teams `replay-test-context` |
Responses are answered from two sources, in order of preference:
@@ -55,6 +56,17 @@ so response stubs only need to be valid enough for the real adapter to proceed.
reconstructs the streamed text and records it as a synthetic `chat.postMessage` so assertions can
treat the reply as one outbound post. `webhookVerifier: () => true` bypasses signature checks (the
fixtures carry sanitized signatures); passing `botUserId` skips the `auth.test` lookup.
+- **Teams** — the only platform whose inbound requests must carry a **real signed token**.
+ `createTeamsAdapter` builds a `@microsoft/teams.apps` `App`, which validates every activity's
+ Bot Framework JWT, and the adapter exposes no bypass: there is no `webhookVerifier` like Slack's,
+ and `TeamsAdapterConfig` cannot pass the Teams SDK's `skipAuth` through. So the helper generates
+ an RSA keypair per run, signs an activity token (issuer `https://api.botframework.com`, audience
+ the app ID, a `serviceurl` claim matching the activity), and serves the matching public JWK from a
+ nocked `https://login.botframework.com/v1/.well-known/keys`. The adapter's own validation then runs
+ for real — `sendUnauthenticatedWebhook` asserts it still rejects. Two non-obvious stubs are also
+ required: the client-credentials token endpoint is **tenant**-scoped, not `botframework.com`-scoped,
+ and the SDK decodes the access token it gets back, so that stub must return a real JWT rather than
+ an opaque string.
- **Linear** — webhooks are HMAC-signed (`linear-signature`) and timestamp-checked, so the helper
refreshes `webhookTimestamp` and signs the body. `@linear/sdk` strictly deserializes typed
entities and lazily fetches relationships, so the GraphQL stub returns fully-shaped entities
diff --git a/packages/cli/src/modules/agents/integrations/__tests__/adapter-logger.test.ts b/packages/cli/src/modules/agents/integrations/__tests__/adapter-logger.test.ts
new file mode 100644
index 00000000000..ae6fe75305c
--- /dev/null
+++ b/packages/cli/src/modules/agents/integrations/__tests__/adapter-logger.test.ts
@@ -0,0 +1,31 @@
+import type { Logger } from '@n8n/backend-common';
+import { mock } from 'vitest-mock-extended';
+
+import { createAdapterLogger } from '../adapter-logger';
+
+describe('createAdapterLogger', () => {
+ it('forwards the prefixed message and drops adapter metadata', () => {
+ const logger = mock();
+
+ createAdapterLogger(logger, '[TestAdapter]').info('sent a message', {
+ text: 'secret message body',
+ signature: 'request-signature',
+ });
+
+ expect(logger.info).toHaveBeenCalledWith('[TestAdapter] sent a message');
+ });
+
+ it.each(['debug', 'info', 'warn', 'error'] as const)('forwards %s', (level) => {
+ const logger = mock();
+
+ createAdapterLogger(logger, '[TestAdapter]')[level]('hello');
+
+ expect(logger[level]).toHaveBeenCalledWith('[TestAdapter] hello');
+ });
+
+ it('returns itself as its own child so adapter scoping keeps the prefix', () => {
+ const adapterLogger = createAdapterLogger(mock(), '[TestAdapter]');
+
+ expect(adapterLogger.child('scope')).toBe(adapterLogger);
+ });
+});
diff --git a/packages/cli/src/modules/agents/integrations/__tests__/agent-chat-subscription.constraint.integration.test.ts b/packages/cli/src/modules/agents/integrations/__tests__/agent-chat-subscription.constraint.integration.test.ts
new file mode 100644
index 00000000000..e81d218542b
--- /dev/null
+++ b/packages/cli/src/modules/agents/integrations/__tests__/agent-chat-subscription.constraint.integration.test.ts
@@ -0,0 +1,64 @@
+import { AgentIntegrationSchema } from '@n8n/api-types';
+import { createTeamProject, testDb, testModules } from '@n8n/backend-test-utils';
+import { Container } from '@n8n/di';
+
+import { AgentChatSubscriptionRepository } from '../../repositories/agent-chat-subscription.repository';
+import { AgentRepository } from '../../repositories/agent.repository';
+
+/**
+ * `agent_chat_subscriptions.integrationType` is guarded by a CHECK constraint
+ * that each new channel has to widen with its own migration. Miss it and the
+ * channel connects, runs, and then fails on the first subscribed message —
+ * which unit tests never see, because they all run on in-memory state.
+ *
+ * The types come from the schema rather than a list here: the mistake this
+ * guards against is forgetting to add a channel, so a hand-written list would
+ * be forgotten in the same edit.
+ */
+const INTEGRATION_TYPES = AgentIntegrationSchema.options.map((option) => option.shape.type.value);
+
+let agentId: string;
+
+beforeAll(async () => {
+ await testModules.loadModules(['agents']);
+ await testDb.init();
+
+ const project = await createTeamProject();
+ const agent = await Container.get(AgentRepository).save(
+ Container.get(AgentRepository).create({
+ name: 'Subscription constraint agent',
+ projectId: project.id,
+ integrations: [],
+ }),
+ );
+ agentId = agent.id;
+});
+
+afterAll(async () => {
+ await testDb.terminate();
+});
+
+describe('agent chat subscription integration types', () => {
+ it('accepts a subscription for every integration type the schema allows', async () => {
+ const repository = Container.get(AgentChatSubscriptionRepository);
+ expect(INTEGRATION_TYPES).toContain('teams');
+
+ for (const integrationType of INTEGRATION_TYPES) {
+ const scope = { agentId, integrationType, credentialId: `cred-${integrationType}` };
+ const threadId = `thread-${integrationType}`;
+
+ await repository.subscribe(scope, threadId);
+
+ await expect(repository.isSubscribed(scope, threadId)).resolves.toBe(true);
+ }
+ });
+
+ // Without this the suite would still pass if a migration dropped the check
+ // altogether, which would make the test above prove nothing.
+ it('rejects a type the constraint does not list', async () => {
+ const repository = Container.get(AgentChatSubscriptionRepository);
+ const scope = { agentId, integrationType: 'not-a-channel', credentialId: 'cred-x' };
+
+ await expect(repository.subscribe(scope, 'thread-x')).rejects.toThrow();
+ });
+});
diff --git a/packages/cli/src/modules/agents/integrations/__tests__/channel-integration-contract.test.ts b/packages/cli/src/modules/agents/integrations/__tests__/channel-integration-contract.test.ts
index fdc3fbd83c7..a4290b40a59 100644
--- a/packages/cli/src/modules/agents/integrations/__tests__/channel-integration-contract.test.ts
+++ b/packages/cli/src/modules/agents/integrations/__tests__/channel-integration-contract.test.ts
@@ -7,6 +7,15 @@ import {
createSlackReplayContext,
type SlackReplayFixtures,
} from './helpers/slack/replay-test-context';
+import { createTeamsReplayContext } from './helpers/teams/replay-test-context';
+import {
+ dmFollowUp,
+ dmMessage,
+ selfMessage,
+ TEAMS_DM_CONVERSATION_ID,
+ TEAMS_SERVICE_URL,
+ TEAMS_USER_ID,
+} from './helpers/teams/synthetic-fixtures';
import {
createTelegramReplayContext,
type TelegramReplayFixtures,
@@ -21,6 +30,7 @@ vi.mock('../esm-loader', () => ({
loadTelegramAdapter: async () => await import('@chat-adapter/telegram'),
loadSlackAdapter: async () => await import('@chat-adapter/slack'),
loadLinearAdapter: async () => await import('@chat-adapter/linear'),
+ loadTeamsAdapter: async () => await import('@chat-adapter/teams'),
}));
const slackFixtures = jsonParse(
@@ -105,3 +115,49 @@ runSharedChannelIntegrationContract({
},
createContext: async () => await createTelegramReplayContext(telegramFixtures),
});
+
+// Teams has no recorded session yet (no tenant in CI), so the contract runs
+// against hand-built activities driven through the real adapter.
+//
+// The adapter encodes its thread id as base64url conversation + service URL;
+// deriving it from the fixture constants keeps the two from drifting apart.
+const teamsThreadId = [
+ 'teams',
+ Buffer.from(TEAMS_DM_CONVERSATION_ID).toString('base64url'),
+ Buffer.from(TEAMS_SERVICE_URL).toString('base64url'),
+].join(':');
+
+runSharedChannelIntegrationContract({
+ name: 'Microsoft Teams',
+ fixtures: { mention: dmMessage, followUp: dmFollowUp, selfMessage },
+ expected: {
+ message: 'hello agent',
+ author: { id: TEAMS_USER_ID, name: 'Alice' },
+ followUpMessage: 'follow up',
+ integrationType: 'teams',
+ context: {
+ integrationConnectionId: 'teams:cred-teams',
+ platform: 'teams',
+ messageId: 'activity-dm-1',
+ interactingUserId: TEAMS_USER_ID,
+ target: {
+ type: 'thread',
+ threadId: teamsThreadId,
+ channelId: teamsThreadId,
+ },
+ },
+ resourceId: TEAMS_USER_ID,
+ firstPost: {
+ type: 'message',
+ text: 'Got it',
+ conversation: { id: TEAMS_DM_CONVERSATION_ID },
+ },
+ respondPost: {
+ type: 'message',
+ text: 'Action response',
+ conversation: { id: TEAMS_DM_CONVERSATION_ID },
+ },
+ respondTarget: { threadId: teamsThreadId },
+ },
+ createContext: async () => await createTeamsReplayContext(),
+});
diff --git a/packages/cli/src/modules/agents/integrations/__tests__/credential-fields.test.ts b/packages/cli/src/modules/agents/integrations/__tests__/credential-fields.test.ts
new file mode 100644
index 00000000000..ae66caca334
--- /dev/null
+++ b/packages/cli/src/modules/agents/integrations/__tests__/credential-fields.test.ts
@@ -0,0 +1,37 @@
+import { UserError } from 'n8n-workflow';
+
+import { credentialField, requireCredentialField } from '../credential-fields';
+
+describe('credentialField', () => {
+ it('trims a pasted value', () => {
+ expect(credentialField({ clientId: ' abc ' }, 'clientId')).toBe('abc');
+ });
+
+ it.each([
+ ['a missing field', {}],
+ ['a non-string field', { clientId: 42 }],
+ ['a whitespace-only field', { clientId: ' ' }],
+ ['a non-record credential', undefined],
+ ])('returns an empty string for %s', (_case, credential) => {
+ expect(credentialField(credential, 'clientId')).toBe('');
+ });
+});
+
+describe('requireCredentialField', () => {
+ it('returns the trimmed value', () => {
+ expect(requireCredentialField({ clientId: ' abc ' }, 'clientId', 'boom')).toBe('abc');
+ });
+
+ it.each([
+ ['a missing field', {}],
+ ['a non-string field', { clientId: 42 }],
+ ['a whitespace-only field', { clientId: ' ' }],
+ ])("throws the caller's message for %s", (_case, credential) => {
+ expect(() => requireCredentialField(credential, 'clientId', 'Needs a client ID.')).toThrow(
+ UserError,
+ );
+ expect(() => requireCredentialField(credential, 'clientId', 'Needs a client ID.')).toThrow(
+ 'Needs a client ID.',
+ );
+ });
+});
diff --git a/packages/cli/src/modules/agents/integrations/__tests__/helpers/teams/replay-test-context.ts b/packages/cli/src/modules/agents/integrations/__tests__/helpers/teams/replay-test-context.ts
new file mode 100644
index 00000000000..fb44a859bba
--- /dev/null
+++ b/packages/cli/src/modules/agents/integrations/__tests__/helpers/teams/replay-test-context.ts
@@ -0,0 +1,206 @@
+import type { StreamChunk } from '@n8n/agents';
+import type { Logger as BackendLogger } from '@n8n/backend-common';
+import { generateKeyPairSync, randomUUID } from 'crypto';
+import jwt from 'jsonwebtoken';
+import nock from 'nock';
+import { mock } from 'vitest-mock-extended';
+
+import type { AgentRepository } from '../../../../repositories/agent.repository';
+import type { ChatInstance } from '../../../chat-integration.service';
+import { ComponentMapper } from '../../../component-mapper';
+import type { IntegrationMessageContext } from '../../../integration-tools';
+import { TeamsIntegration } from '../../../platforms/teams-integration';
+import {
+ createReplayContextSetup,
+ type ReplayApiCall,
+ type ReplayContextSetup,
+ type ReplayWebhookHandler,
+ sendJsonWebhook,
+} from '../replay-test-helpers';
+import {
+ TEAMS_APP_ID,
+ TEAMS_SERVICE_URL,
+ TEAMS_CLIENT_SECRET,
+ TEAMS_TENANT_ID,
+} from './synthetic-fixtures';
+
+/**
+ * Bot Framework constants the Teams SDK derives from its `PUBLIC` cloud config.
+ * Pinned here so a stub drifting from the SDK fails loudly instead of silently
+ * letting an unauthenticated request through.
+ */
+const JWKS_ORIGIN = 'https://login.botframework.com';
+const JWKS_PATH = '/v1/.well-known/keys';
+const TOKEN_ISSUER = 'https://api.botframework.com';
+const LOGIN_ORIGIN = 'https://login.microsoftonline.com';
+
+export interface TeamsReplayContext extends Omit {
+ chat: ChatInstance;
+ sendWebhook: (payload: unknown) => Promise;
+ /** Same payload, no Authorization header — proves the token check is live. */
+ sendUnauthenticatedWebhook: (payload: unknown) => Promise;
+ latestContext: () => IntegrationMessageContext | undefined;
+ latestThreadId: () => string | undefined;
+ lastPost: () => ReplayApiCall | undefined;
+ lastEdit: () => ReplayApiCall | undefined;
+ lastPostedMessageId: () => string | undefined;
+}
+
+/**
+ * Signs real RS256 Bot Framework tokens, because the adapter exposes no
+ * verification bypass. The matching JWK is served from the stubbed JWKS, so the
+ * adapter's own validation stays under test.
+ */
+let cachedSigner: ReturnType | undefined;
+
+/** Reused across contexts: no test needs distinct key material. */
+function createBotFrameworkSigner() {
+ return (cachedSigner ??= buildBotFrameworkSigner());
+}
+
+function buildBotFrameworkSigner() {
+ const keyId = randomUUID();
+ const { privateKey, publicKey } = generateKeyPairSync('rsa', { modulusLength: 2048 });
+ const jwk = publicKey.export({ format: 'jwk' });
+
+ return {
+ jwks: { keys: [{ ...jwk, kid: keyId, use: 'sig', alg: 'RS256' }] },
+ /**
+ * The Teams SDK decodes the token it gets back from the mint to read its
+ * claims, so an opaque placeholder string fails.
+ */
+ accessToken: () =>
+ jwt.sign({ aud: TOKEN_ISSUER, iss: TOKEN_ISSUER, appid: TEAMS_APP_ID }, privateKey, {
+ algorithm: 'RS256',
+ expiresIn: '1h',
+ keyid: keyId,
+ }),
+ sign: () =>
+ jwt.sign(
+ {
+ serviceurl: TEAMS_SERVICE_URL,
+ aud: TEAMS_APP_ID,
+ iss: TOKEN_ISSUER,
+ appid: TEAMS_APP_ID,
+ sub: TEAMS_APP_ID,
+ },
+ privateKey,
+ { algorithm: 'RS256', expiresIn: '1h', keyid: keyId },
+ ),
+ };
+}
+
+function installTeamsApiStub(jwks: object, accessToken: string) {
+ const apiCalls: ReplayApiCall[] = [];
+ const serviceUrl = new URL(TEAMS_SERVICE_URL);
+
+ nock(JWKS_ORIGIN).persist().get(JWKS_PATH).reply(200, jwks);
+
+ nock(LOGIN_ORIGIN)
+ .persist()
+ .post(/\/[^/]+\/oauth2\/v2\.0\/token/)
+ .reply(200, {
+ token_type: 'Bearer',
+ expires_in: 3599,
+ ext_expires_in: 3599,
+ access_token: accessToken,
+ });
+
+ // Recorded as `sendActivity` so `lastPost()` reads like the other platforms'.
+ let nextMessageId = 1000;
+ const postedMessageIds: string[] = [];
+ nock(serviceUrl.origin)
+ .persist()
+ .post(/\/v3\/conversations\/.+\/activities.*/)
+ .reply(function (_uri, body) {
+ apiCalls.push({
+ method: 'sendActivity',
+ body: (typeof body === 'object' && body !== null ? body : {}) as Record,
+ });
+ const id = `message-${nextMessageId++}`;
+ postedMessageIds.push(id);
+ return [200, { id }];
+ });
+
+ nock(serviceUrl.origin)
+ .persist()
+ .put(/\/v3\/conversations\/.+\/activities\/.+/)
+ .reply(function (_uri, body) {
+ apiCalls.push({
+ method: 'updateActivity',
+ body: (typeof body === 'object' && body !== null ? body : {}) as Record,
+ });
+ return [200, { id: 'message-edited' }];
+ });
+
+ return { apiCalls, postedMessageIds, restore: () => nock.cleanAll() };
+}
+
+export async function createTeamsReplayContext(
+ options: { stream?: StreamChunk[] } = {},
+): Promise {
+ const signer = createBotFrameworkSigner();
+ const stub = installTeamsApiStub(signer.jwks, signer.accessToken());
+
+ // Dynamic imports — the chat packages are ESM-only. Production routes through
+ // esm-loader to dodge the CJS transform; vitest loads ESM natively.
+ const { createTeamsAdapter } = await import('@chat-adapter/teams');
+ const { Chat } = await import('chat');
+ const { createMemoryState } = await import('@chat-adapter/state-memory');
+
+ const adapter = createTeamsAdapter({
+ appId: TEAMS_APP_ID,
+ appPassword: TEAMS_CLIENT_SECRET,
+ appTenantId: TEAMS_TENANT_ID,
+ appType: 'SingleTenant',
+ });
+ const chat = new Chat({
+ userName: 'n8n-agent-agent-1',
+ adapters: { teams: adapter } as unknown as Record,
+ state: createMemoryState(),
+ });
+
+ const setup = createReplayContextSetup({
+ chat: chat as never,
+ integrationImpl: new TeamsIntegration(mock(), mock()),
+ integration: { type: 'teams', credentialId: 'cred-teams', settings: undefined },
+ componentMapper: new ComponentMapper(),
+ stream: options.stream,
+ });
+
+ await chat.initialize();
+
+ const webhooks = chat.webhooks as unknown as Record;
+ const post = async (payload: unknown, headers: Headers) =>
+ await sendJsonWebhook(
+ async (request, requestOptions) => await webhooks.teams(request, requestOptions),
+ 'https://n8n.example.com/rest/projects/project-1/agents/v2/agent-1/webhooks/teams',
+ payload,
+ headers,
+ );
+
+ const sendWebhook = async (payload: unknown) => {
+ const headers = new Headers();
+ headers.set('authorization', `Bearer ${signer.sign()}`);
+ return await post(payload, headers);
+ };
+
+ const lastCall = (method: string) =>
+ stub.apiCalls.filter((call) => call.method === method).at(-1);
+
+ return {
+ ...setup,
+ chat: chat as unknown as ChatInstance,
+ sendWebhook,
+ sendUnauthenticatedWebhook: async (payload: unknown) => await post(payload, new Headers()),
+ latestContext: setup.latestContext,
+ latestThreadId: setup.latestThreadId,
+ lastPost: () => lastCall('sendActivity'),
+ lastEdit: () => lastCall('updateActivity'),
+ lastPostedMessageId: () => stub.postedMessageIds.at(-1),
+ shutdown: async () => {
+ stub.restore();
+ await setup.shutdown();
+ },
+ };
+}
diff --git a/packages/cli/src/modules/agents/integrations/__tests__/helpers/teams/synthetic-fixtures.ts b/packages/cli/src/modules/agents/integrations/__tests__/helpers/teams/synthetic-fixtures.ts
new file mode 100644
index 00000000000..9f27104a355
--- /dev/null
+++ b/packages/cli/src/modules/agents/integrations/__tests__/helpers/teams/synthetic-fixtures.ts
@@ -0,0 +1,76 @@
+/**
+ * Hand-built Bot Framework activities for the Teams channel tests.
+ *
+ * Teams has no recorded session yet, so these are written against Microsoft's
+ * documented activity schema rather than captured from a tenant. They are fed
+ * to the real `@chat-adapter/teams` adapter, so any shape the adapter rejects
+ * fails the test rather than passing silently.
+ */
+
+export const TEAMS_APP_ID = '11111111-2222-3333-4444-555555555555';
+export const TEAMS_CLIENT_SECRET = 'test-client-secret';
+export const TEAMS_TENANT_ID = '99999999-8888-7777-6666-555555555555';
+export const TEAMS_SERVICE_URL = 'https://smba.trafficmanager.net/amer';
+export const TEAMS_DM_CONVERSATION_ID = 'a:1dm_conversation_alice';
+export const TEAMS_USER_ID = '29:alice-teams-id';
+const TEAMS_USER_AAD_ID = 'aad-alice';
+
+export interface TeamsActivityFixture extends Record {
+ type: string;
+ id: string;
+ serviceUrl: string;
+ conversation: { id: string; conversationType?: string; tenantId?: string; isGroup?: boolean };
+ from: { id: string; name?: string; aadObjectId?: string };
+ recipient: { id: string; name?: string };
+ channelId: string;
+ channelData?: Record;
+}
+
+function baseActivity(overrides: Partial = {}): TeamsActivityFixture {
+ return {
+ type: 'message',
+ id: 'activity-1',
+ timestamp: new Date('2026-01-15T10:00:00.000Z').toISOString(),
+ serviceUrl: TEAMS_SERVICE_URL,
+ channelId: 'msteams',
+ conversation: {
+ id: TEAMS_DM_CONVERSATION_ID,
+ conversationType: 'personal',
+ tenantId: TEAMS_TENANT_ID,
+ },
+ from: { id: TEAMS_USER_ID, name: 'Alice', aadObjectId: TEAMS_USER_AAD_ID },
+ recipient: { id: `28:${TEAMS_APP_ID}`, name: 'n8n Agent' },
+ channelData: { tenant: { id: TEAMS_TENANT_ID } },
+ ...overrides,
+ };
+}
+
+/** A first direct message from a user — the Teams equivalent of a mention. */
+export const dmMessage: TeamsActivityFixture = baseActivity({
+ id: 'activity-dm-1',
+ text: 'hello agent',
+});
+
+export const dmFollowUp: TeamsActivityFixture = baseActivity({
+ id: 'activity-dm-2',
+ text: 'follow up',
+});
+
+/** A message the bot itself authored — must never trigger the agent. */
+export const selfMessage: TeamsActivityFixture = baseActivity({
+ id: 'activity-self-1',
+ text: 'agent talking to itself',
+ from: { id: `28:${TEAMS_APP_ID}`, name: 'n8n Agent' },
+});
+
+/**
+ * An Adaptive Card button click. Teams delivers `Action.Submit` as a message
+ * activity carrying `value` and no text.
+ */
+export function cardAction(value: Record, replyToId: string) {
+ return baseActivity({
+ id: 'activity-action-1',
+ replyToId,
+ value,
+ });
+}
diff --git a/packages/cli/src/modules/agents/integrations/__tests__/teams-integration.test.ts b/packages/cli/src/modules/agents/integrations/__tests__/teams-integration.test.ts
new file mode 100644
index 00000000000..f5a2e84b68f
--- /dev/null
+++ b/packages/cli/src/modules/agents/integrations/__tests__/teams-integration.test.ts
@@ -0,0 +1,281 @@
+import type { Logger } from '@n8n/backend-common';
+import { UserError } from 'n8n-workflow';
+import { mock } from 'vitest-mock-extended';
+
+import { ConflictError } from '@/errors/response-errors/conflict.error';
+
+import type { Agent } from '../../entities/agent.entity';
+import type { AgentRepository } from '../../repositories/agent.repository';
+import type { AgentChatIntegrationContext } from '../agent-chat-integration';
+import {
+ TEAMS_APP_ID as CLIENT_ID,
+ TEAMS_CLIENT_SECRET as CLIENT_SECRET,
+ TEAMS_TENANT_ID as TENANT_ID,
+} from './helpers/teams/synthetic-fixtures';
+import { TeamsIntegration } from '../platforms/teams-integration';
+
+const createTeamsAdapter = vi.fn(() => ({ name: 'teams' }));
+
+// The adapter is ESM-only; production reaches it through esm-loader's
+// `new Function()` indirection, which cannot run under vitest. A spy stands in
+// here so the adapter config this integration builds can be asserted directly.
+vi.mock('../esm-loader', () => ({
+ loadTeamsAdapter: async () => ({ createTeamsAdapter }),
+}));
+
+const AGENT_ID = 'agent-1';
+const CREDENTIAL_ID = 'cred-teams';
+
+function servicePrincipalCredential(overrides: Record = {}) {
+ return {
+ authentication: 'clientSecret',
+ tenantId: TENANT_ID,
+ clientId: CLIENT_ID,
+ clientSecret: CLIENT_SECRET,
+ graphApiBaseUrl: 'https://graph.microsoft.com',
+ ...overrides,
+ };
+}
+
+function connectionContext(
+ credential: Record = servicePrincipalCredential(),
+): AgentChatIntegrationContext {
+ return {
+ agentId: AGENT_ID,
+ projectId: 'project-1',
+ credentialId: CREDENTIAL_ID,
+ credential,
+ integration: { type: 'teams', credentialId: CREDENTIAL_ID, settings: undefined },
+ ingressEnabled: true,
+ webhookUrlFor: (platform: string) => `https://n8n.example.com/webhooks/${platform}`,
+ };
+}
+
+describe('TeamsIntegration', () => {
+ let integration: TeamsIntegration;
+ let agentRepository: ReturnType>;
+
+ beforeEach(() => {
+ createTeamsAdapter.mockClear();
+ agentRepository = mock();
+ agentRepository.findByIntegrationCredential.mockResolvedValue([]);
+ integration = new TeamsIntegration(mock(), agentRepository);
+ });
+
+ describe('platform capabilities', () => {
+ it('reuses the Entra credential, stays hidden, buffers, and settles in place', () => {
+ expect(integration).toMatchObject({
+ credentialTypes: ['microsoftEntraServicePrincipalApi'],
+ internal: true,
+ disableStreaming: true,
+ // Left at the base-class default: full Adaptive Card payloads fit, so
+ // no callback store is needed.
+ needsShortCallbackData: false,
+ deleteActionMessageBeforeResume: false,
+ });
+ });
+
+ // Also the base-class default; asserted because Teams receiving only
+ // webhooks is what makes that default correct here.
+ it('runs on every main', () => {
+ expect(integration.requiresLeader()).toBe(false);
+ });
+ });
+
+ describe('createAdapter', () => {
+ it('maps the credential onto a single-tenant bot identity', async () => {
+ await integration.createAdapter(connectionContext());
+
+ expect(createTeamsAdapter).toHaveBeenCalledWith(
+ expect.objectContaining({
+ appId: CLIENT_ID,
+ appPassword: CLIENT_SECRET,
+ appTenantId: TENANT_ID,
+ appType: 'SingleTenant',
+ }),
+ );
+ });
+
+ it('trims whitespace pasted into credential fields', async () => {
+ await integration.createAdapter(
+ connectionContext(servicePrincipalCredential({ clientId: ` ${CLIENT_ID} ` })),
+ );
+
+ expect(createTeamsAdapter).toHaveBeenCalledWith(
+ expect.objectContaining({ appId: CLIENT_ID }),
+ );
+ });
+
+ it('rejects a credential that uses certificate authentication', async () => {
+ const credential = servicePrincipalCredential({
+ authentication: 'certificate',
+ clientSecret: '',
+ privateKey: 'pem-key',
+ certificate: 'pem-cert',
+ });
+
+ const rejected = integration.createAdapter(connectionContext(credential));
+ await expect(rejected).rejects.toThrow(UserError);
+ await expect(rejected).rejects.toThrow(/certificate authentication/i);
+ expect(createTeamsAdapter).not.toHaveBeenCalled();
+ });
+
+ it('pins the Bot Connector host instead of letting an env var choose it', async () => {
+ await integration.createAdapter(connectionContext());
+
+ expect(createTeamsAdapter).toHaveBeenCalledWith(
+ expect.objectContaining({ apiUrl: 'https://smba.trafficmanager.net/teams' }),
+ );
+ });
+
+ it.each([
+ 'https://graph.microsoft.us',
+ 'https://dod-graph.microsoft.us',
+ 'https://microsoftgraph.chinacloudapi.cn',
+ ])('rejects the sovereign cloud %s', async (graphApiBaseUrl) => {
+ await expect(
+ integration.createAdapter(
+ connectionContext(servicePrincipalCredential({ graphApiBaseUrl })),
+ ),
+ ).rejects.toThrow(/global Microsoft cloud/);
+ expect(createTeamsAdapter).not.toHaveBeenCalled();
+ });
+
+ it.each([
+ 'tenant/id',
+ 'tenant id',
+ 'tenant@id',
+ 'tenant?id',
+ 'tenant#id',
+ // Dot segments pass an alphabet check, then normalize the tenant away.
+ '.',
+ '..',
+ '-',
+ 'a-',
+ '-a',
+ 'a..b',
+ // A verified domain always has at least two labels.
+ 'contoso',
+ ])('rejects the malformed tenant ID %p', async (tenantId) => {
+ await expect(
+ integration.createAdapter(connectionContext(servicePrincipalCredential({ tenantId }))),
+ ).rejects.toThrow(/invalid Directory \(tenant\) ID/);
+ expect(createTeamsAdapter).not.toHaveBeenCalled();
+ });
+
+ it.each([TENANT_ID, 'contoso.onmicrosoft.com', 'a.b', 'my-tenant.example.com'])(
+ 'accepts the tenant ID %p',
+ async (tenantId) => {
+ await expect(
+ integration.createAdapter(connectionContext(servicePrincipalCredential({ tenantId }))),
+ ).resolves.toBeDefined();
+ expect(createTeamsAdapter).toHaveBeenCalledWith(
+ expect.objectContaining({ appTenantId: tenantId }),
+ );
+ },
+ );
+
+ it.each([
+ ['clientId', /Application \(client\) ID/],
+ ['clientSecret', /Client Secret/],
+ ['tenantId', /Directory \(tenant\) ID/],
+ ])('rejects a credential with no %s', async (field, message) => {
+ await expect(
+ integration.createAdapter(connectionContext(servicePrincipalCredential({ [field]: '' }))),
+ ).rejects.toThrow(message);
+ expect(createTeamsAdapter).not.toHaveBeenCalled();
+ });
+ });
+
+ describe('onBeforeConnect', () => {
+ it('rejects a credential another agent already connected', async () => {
+ agentRepository.findByIntegrationCredential.mockResolvedValue([
+ mock({ name: 'Support bot' }),
+ ]);
+
+ await expect(integration.onBeforeConnect(connectionContext())).rejects.toThrow(ConflictError);
+ });
+
+ it('surfaces a broken credential before the channel connects', async () => {
+ await expect(
+ integration.onBeforeConnect(
+ connectionContext(servicePrincipalCredential({ tenantId: '' })),
+ ),
+ ).rejects.toThrow(UserError);
+ });
+ });
+
+ describe('formatActionDecisionMessage', () => {
+ const user = { userId: 'u-1', userName: 'alice', fullName: 'Alice', isBot: false, isMe: false };
+
+ it.each([
+ [true, '✅ Approved by Alice'],
+ [false, '🚫 Declined by Alice'],
+ ])('names the decision when one was resolved: %p', (approved, expected) => {
+ expect(
+ integration.formatActionDecisionMessage({
+ approved,
+ raw: {},
+ user,
+ } as Parameters[0]),
+ ).toBe(expected);
+ });
+
+ it.each([
+ [{ userId: 'u-1', userName: 'alice', fullName: 'Alice' }, 'Alice'],
+ [{ userId: 'u-1', userName: 'alice', fullName: '' }, 'alice'],
+ [{ userId: 'u-1', userName: '', fullName: '' }, 'u-1'],
+ ])('resolves the responder from %p', (responder, expected) => {
+ expect(
+ integration.formatActionDecisionMessage({
+ approved: true,
+ raw: {},
+ user: { ...responder, isBot: false, isMe: false },
+ } as Parameters[0]),
+ ).toBe(`✅ Approved by ${expected}`);
+ });
+
+ // Without the CallbackStore the resume handler resolves neither the
+ // decision nor the label, so this generic wording is what a Teams
+ // approval actually settles to today.
+ it('falls back to a generic outcome when no decision reaches it', () => {
+ expect(
+ integration.formatActionDecisionMessage({
+ raw: {},
+ user,
+ } as Parameters[0]),
+ ).toBe('✅ Action selected by Alice');
+ });
+ });
+
+ describe('normalizeComponents', () => {
+ it('converts select options into individual buttons', () => {
+ expect(
+ integration.normalizeComponents([
+ {
+ type: 'select',
+ label: 'Environment',
+ options: [
+ { label: 'Staging', value: 'staging' },
+ { label: 'Production', value: 'production' },
+ ],
+ },
+ ]),
+ ).toEqual([
+ { type: 'button', label: 'Staging', value: 'staging' },
+ { type: 'button', label: 'Production', value: 'production' },
+ ]);
+ });
+
+ it('leaves components Adaptive Cards render natively untouched', () => {
+ const components = [
+ { type: 'section' as const, text: 'Deploy summary' },
+ { type: 'image' as const, url: 'https://example.com/chart.png', altText: 'Chart' },
+ { type: 'divider' as const },
+ { type: 'button' as const, label: 'Approve', value: 'true' },
+ ];
+
+ expect(integration.normalizeComponents(components)).toEqual(components);
+ });
+ });
+});
diff --git a/packages/cli/src/modules/agents/integrations/adapter-logger.ts b/packages/cli/src/modules/agents/integrations/adapter-logger.ts
new file mode 100644
index 00000000000..02a93f42a12
--- /dev/null
+++ b/packages/cli/src/modules/agents/integrations/adapter-logger.ts
@@ -0,0 +1,23 @@
+import type { Logger } from '@n8n/backend-common';
+import type { Logger as ChatLogger } from 'chat';
+
+/**
+ * Only the message string is forwarded. Adapter metadata arguments are dropped:
+ * the Discord adapter was observed passing message text, IDs, request
+ * signatures and public keys that way, none of which belongs in instance logs.
+ */
+export function createAdapterLogger(logger: Logger, prefix: string): ChatLogger {
+ const forward =
+ (level: 'debug' | 'info' | 'warn' | 'error') =>
+ (message: string, ..._args: unknown[]) => {
+ logger[level](`${prefix} ${message}`);
+ };
+ const chatLogger: ChatLogger = {
+ child: () => chatLogger,
+ debug: forward('debug'),
+ info: forward('info'),
+ warn: forward('warn'),
+ error: forward('error'),
+ };
+ return chatLogger;
+}
diff --git a/packages/cli/src/modules/agents/integrations/component-mapper.ts b/packages/cli/src/modules/agents/integrations/component-mapper.ts
index a95c5340e17..e84e2446cd5 100644
--- a/packages/cli/src/modules/agents/integrations/component-mapper.ts
+++ b/packages/cli/src/modules/agents/integrations/component-mapper.ts
@@ -75,6 +75,24 @@ interface ComponentRenderContext {
makeButton: (label: string, rawValue: string, style?: string) => Promise;
}
+/**
+ * Shared by the platforms whose rich cards have no select control. Telegram
+ * keeps its own override because it also rewrites images.
+ */
+export function expandSelectsToButtons(components: SuspendComponent[]): SuspendComponent[] {
+ const normalized: SuspendComponent[] = [];
+ for (const c of components) {
+ if (c.type === 'select' || c.type === 'radio_select') {
+ for (const opt of c.options ?? []) {
+ normalized.push({ type: 'button', label: opt.label, value: opt.value });
+ }
+ continue;
+ }
+ normalized.push(c);
+ }
+ return normalized;
+}
+
/**
* Converts agent SDK suspend payloads into Chat SDK Card elements.
*
diff --git a/packages/cli/src/modules/agents/integrations/credential-fields.ts b/packages/cli/src/modules/agents/integrations/credential-fields.ts
new file mode 100644
index 00000000000..31cb6515cba
--- /dev/null
+++ b/packages/cli/src/modules/agents/integrations/credential-fields.ts
@@ -0,0 +1,30 @@
+import { isRecord } from '@n8n/utils/is-record';
+import { UserError } from 'n8n-workflow';
+
+/**
+ * Reading a credential field is shared rather than repeated per platform so the
+ * trimming stays uniform: pasted IDs and secrets routinely carry surrounding
+ * whitespace, and a channel that forgets to trim fails against the vendor with
+ * no clue why.
+ */
+export function credentialField(credential: unknown, field: string): string {
+ if (!isRecord(credential)) return '';
+ const value = credential[field];
+ return typeof value === 'string' ? value.trim() : '';
+}
+
+/**
+ * The caller supplies the whole message, because a useful one names where in
+ * the vendor's console the value comes from.
+ *
+ * Throws `UserError`: a missing field is the user's to fix, not a bug.
+ */
+export function requireCredentialField(
+ credential: unknown,
+ field: string,
+ message: string,
+): string {
+ const value = credentialField(credential, field);
+ if (!value) throw new UserError(message);
+ return value;
+}
diff --git a/packages/cli/src/modules/agents/integrations/esm-loader.ts b/packages/cli/src/modules/agents/integrations/esm-loader.ts
index 05a20c2f222..b04f956b929 100644
--- a/packages/cli/src/modules/agents/integrations/esm-loader.ts
+++ b/packages/cli/src/modules/agents/integrations/esm-loader.ts
@@ -40,6 +40,10 @@ export async function loadDiscordAdapter() {
return await esmImport('@chat-adapter/discord');
}
+export async function loadTeamsAdapter() {
+ return await esmImport('@chat-adapter/teams');
+}
+
export async function loadMemoryState() {
return await esmImport('@chat-adapter/state-memory');
}
diff --git a/packages/cli/src/modules/agents/integrations/platforms/__tests__/teams/synthetic-integration.test.ts b/packages/cli/src/modules/agents/integrations/platforms/__tests__/teams/synthetic-integration.test.ts
new file mode 100644
index 00000000000..c44036715b6
--- /dev/null
+++ b/packages/cli/src/modules/agents/integrations/platforms/__tests__/teams/synthetic-integration.test.ts
@@ -0,0 +1,189 @@
+import { isRecord } from '@n8n/utils/is-record';
+
+import { createTeamsReplayContext } from '../../../__tests__/helpers/teams/replay-test-context';
+import {
+ cardAction,
+ dmFollowUp,
+ dmMessage,
+ selfMessage,
+ TEAMS_DM_CONVERSATION_ID,
+ TEAMS_USER_ID,
+} from '../../../__tests__/helpers/teams/synthetic-fixtures';
+
+// The chat SDK + adapters are ESM-only. Production loads them via esm-loader's
+// `new Function()` hack to dodge the CJS transform, which can't run under vitest;
+// redirect the loaders to native dynamic imports so the real adapter is used.
+vi.mock('../../../esm-loader', () => ({
+ loadChatSdk: async () => await import('chat'),
+ loadMemoryState: async () => await import('@chat-adapter/state-memory'),
+ loadTeamsAdapter: async () => await import('@chat-adapter/teams'),
+}));
+
+function cardActions(body: unknown): Array> {
+ const found: Array> = [];
+ const visit = (node: unknown) => {
+ if (Array.isArray(node)) return node.forEach(visit);
+ if (!isRecord(node)) return;
+ if (node.type === 'Action.Submit') found.push(node);
+ Object.values(node).forEach(visit);
+ };
+ visit(body);
+ return found;
+}
+
+describe('Microsoft Teams integration scenarios', () => {
+ it('rejects an activity that carries no Bot Framework token', async () => {
+ const ctx = await createTeamsReplayContext();
+ try {
+ const response = await ctx.sendUnauthenticatedWebhook(dmMessage);
+
+ expect(response.status).toBe(401);
+ expect(ctx.agentExecutor.executeForChatPublished).not.toHaveBeenCalled();
+ } finally {
+ await ctx.shutdown();
+ }
+ });
+
+ it('routes a Teams direct message to the agent and replies in the same conversation', async () => {
+ const ctx = await createTeamsReplayContext();
+ try {
+ await expect(ctx.sendWebhook(dmMessage)).resolves.toMatchObject({ status: 200 });
+
+ expect(ctx.agentExecutor.executeForChatPublished).toHaveBeenCalledTimes(1);
+ expect(ctx.agentExecutor.executeForChatPublished).toHaveBeenCalledWith(
+ expect.objectContaining({
+ agentId: 'agent-1',
+ projectId: 'project-1',
+ message: 'hello agent',
+ author: { id: TEAMS_USER_ID, name: 'Alice' },
+ integrationType: 'teams',
+ }),
+ );
+ expect(ctx.lastPost()?.body).toMatchObject({
+ type: 'message',
+ text: 'Got it',
+ conversation: { id: TEAMS_DM_CONVERSATION_ID },
+ });
+ } finally {
+ await ctx.shutdown();
+ }
+ });
+
+ it('routes a follow-up in the same direct message conversation to the same session', async () => {
+ const ctx = await createTeamsReplayContext();
+ try {
+ await ctx.sendWebhook(dmMessage);
+ const firstThreadId = ctx.latestThreadId();
+
+ await ctx.sendWebhook(dmFollowUp);
+
+ expect(ctx.agentExecutor.executeForChatPublished).toHaveBeenCalledTimes(2);
+ expect(ctx.agentExecutor.executeForChatPublished).toHaveBeenLastCalledWith(
+ expect.objectContaining({ message: 'follow up' }),
+ );
+ expect(ctx.latestThreadId()).toBe(firstThreadId);
+ } finally {
+ await ctx.shutdown();
+ }
+ });
+
+ it('ignores a message the bot itself authored', async () => {
+ const ctx = await createTeamsReplayContext();
+ try {
+ await ctx.sendWebhook(selfMessage);
+
+ expect(ctx.agentExecutor.executeForChatPublished).not.toHaveBeenCalled();
+ expect(ctx.lastPost()).toBeUndefined();
+ } finally {
+ await ctx.shutdown();
+ }
+ });
+
+ it('renders a suspended select as individual Adaptive Card buttons', async () => {
+ const ctx = await createTeamsReplayContext({
+ stream: [
+ {
+ type: 'tool-call-suspended',
+ runId: 'run-select-1',
+ toolCallId: 'tool-select-1',
+ toolName: 'select',
+ suspendPayload: {
+ type: 'form',
+ toolName: 'pick_environment',
+ displayName: 'Pick an environment',
+ components: [
+ {
+ type: 'select',
+ label: 'Environment',
+ options: [
+ { label: 'Staging', value: 'staging' },
+ { label: 'Production', value: 'production' },
+ ],
+ },
+ ],
+ },
+ },
+ { type: 'finish', finishReason: 'stop' },
+ ],
+ });
+ try {
+ await ctx.sendWebhook(dmMessage);
+
+ const actions = cardActions(ctx.lastPost()?.body);
+ expect(actions.map((action) => action.title)).toEqual(['Staging', 'Production']);
+ } finally {
+ await ctx.shutdown();
+ }
+ });
+
+ it('resumes a suspended approval from an Adaptive Card button click', async () => {
+ const ctx = await createTeamsReplayContext({
+ stream: [
+ {
+ type: 'tool-call-suspended',
+ runId: 'run-card-1',
+ toolCallId: 'tool-card-1',
+ toolName: 'approval',
+ suspendPayload: {
+ type: 'approval',
+ toolName: 'send_teams_message',
+ displayName: 'Send Teams message',
+ args: { text: 'Continue?' },
+ },
+ },
+ { type: 'finish', finishReason: 'stop' },
+ ],
+ });
+ try {
+ await ctx.sendWebhook(dmMessage);
+
+ const cardPost = ctx.lastPost();
+ const cardMessageId = ctx.lastPostedMessageId();
+ if (!cardMessageId) throw new Error('Expected the approval card to have been posted');
+ const actions = cardActions(cardPost?.body);
+ const approve = actions[0];
+ if (!approve) throw new Error('Expected an Adaptive Card action on the approval card');
+
+ ctx.nextStream([
+ { type: 'text-delta', id: 'resume-text', delta: 'Card handled' },
+ { type: 'finish', finishReason: 'stop' },
+ ]);
+ await ctx.sendWebhook(cardAction(approve.data as Record, cardMessageId));
+
+ expect(ctx.agentExecutor.resumeForChat).toHaveBeenCalledWith(
+ expect.objectContaining({
+ runId: 'run-card-1',
+ toolCallId: 'tool-card-1',
+ integrationType: 'teams',
+ }),
+ );
+
+ // Generic wording: no CallbackStore, so no decision reaches the formatter.
+ expect(ctx.lastEdit()?.body).toMatchObject({
+ text: '✅ Action selected by Alice',
+ });
+ } finally {
+ await ctx.shutdown();
+ }
+ });
+});
diff --git a/packages/cli/src/modules/agents/integrations/platforms/discord-integration.ts b/packages/cli/src/modules/agents/integrations/platforms/discord-integration.ts
index c529b9f9839..c075b34fff9 100644
--- a/packages/cli/src/modules/agents/integrations/platforms/discord-integration.ts
+++ b/packages/cli/src/modules/agents/integrations/platforms/discord-integration.ts
@@ -4,7 +4,7 @@ import { type HttpRequestClient, OutboundHttp } from '@n8n/backend-network';
import { OnLeaderStepdown, OnLeaderTakeover, OnShutdown } from '@n8n/decorators';
import { Service } from '@n8n/di';
import { isRecord } from '@n8n/utils/is-record';
-import type { Logger as ChatLogger, Message, Thread } from 'chat';
+import type { Message, Thread } from 'chat';
import escapeRegExp from 'lodash/escapeRegExp';
import { InstanceSettings } from 'n8n-core';
@@ -27,7 +27,9 @@ import {
type WebhookRequestResolution,
} from '../agent-chat-integration';
import type { ChatInstance } from '../chat-integration.service';
-import type { SuspendComponent } from '../component-mapper';
+import { createAdapterLogger } from '../adapter-logger';
+import { requireCredentialField } from '../credential-fields';
+import { expandSelectsToButtons, type SuspendComponent } from '../component-mapper';
import { assertCredentialNotClaimed } from '../credential-claim';
import { loadDiscordAdapter } from '../esm-loader';
import type { ReplyExpectation } from '../integration-tools';
@@ -236,7 +238,7 @@ export class DiscordIntegration extends AgentChatIntegration {
applicationId,
mentionRoleIds: [],
apiUrl: DISCORD_API_URL,
- logger: this.createAdapterLogger(),
+ logger: createAdapterLogger(this.logger, '[DiscordAdapter]'),
});
this.pendingConnections.set(this.sessionKey(ctx), {
@@ -405,21 +407,7 @@ export class DiscordIntegration extends AgentChatIntegration {
}
normalizeComponents(components: SuspendComponent[]): SuspendComponent[] {
- const normalized: SuspendComponent[] = [];
- for (const c of components) {
- switch (c.type) {
- case 'select':
- case 'radio_select':
- // Discord embeds have no select menu, so offer each option as a button.
- for (const opt of c.options ?? []) {
- normalized.push({ type: 'button', label: opt.label, value: opt.value });
- }
- break;
- default:
- normalized.push(c);
- }
- }
- return normalized;
+ return expandSelectsToButtons(components);
}
private sessionKey(ctx: AgentChatIntegrationContext): string {
@@ -478,33 +466,12 @@ export class DiscordIntegration extends AgentChatIntegration {
}
}
- /**
- * Adapter-compatible logger that forwards only the message string into n8n.
- * Pinned adapter 4.28.1 attaches message text, IDs, signatures, and public
- * keys as metadata arguments — never forward those.
- */
- private createAdapterLogger(): ChatLogger {
- const forward =
- (level: 'debug' | 'info' | 'warn' | 'error') =>
- (message: string, ..._args: unknown[]) => {
- this.logger[level](`[DiscordAdapter] ${message}`);
- };
- const logger: ChatLogger = {
- child: () => logger,
- debug: forward('debug'),
- info: forward('info'),
- warn: forward('warn'),
- error: forward('error'),
- };
- return logger;
- }
-
// ---------------------------------------------------------------------------
// Credential extraction
// ---------------------------------------------------------------------------
private extractBotToken(credential: Record): string {
- return this.requireCredentialField(
+ return requireCredentialField(
credential,
'botToken',
'The Discord credential is missing a Bot Token. Copy it from the Bot section of the Discord Developer Portal.',
@@ -518,7 +485,7 @@ export class DiscordIntegration extends AgentChatIntegration {
* fields fails the connect before the agent is published.
*/
private extractPublicKey(credential: Record): string {
- return this.requireCredentialField(
+ return requireCredentialField(
credential,
'publicKey',
'The Discord credential is missing a Public Key. Copy it from the application General Information page in the Discord Developer Portal.',
@@ -526,23 +493,13 @@ export class DiscordIntegration extends AgentChatIntegration {
}
private extractApplicationId(credential: Record): string {
- return this.requireCredentialField(
+ return requireCredentialField(
credential,
'applicationId',
'The Discord credential is missing an Application ID. Copy it from the application General Information page in the Discord Developer Portal.',
);
}
- private requireCredentialField(
- credential: Record,
- field: string,
- message: string,
- ): string {
- const value = credential[field];
- if (typeof value === 'string' && value.trim()) return value.trim();
- throw new Error(message);
- }
-
/**
* Approval cards render as embeds, so the original copy is usually the first
* embed's description rather than the message content.
diff --git a/packages/cli/src/modules/agents/integrations/platforms/teams-integration.ts b/packages/cli/src/modules/agents/integrations/platforms/teams-integration.ts
new file mode 100644
index 00000000000..4baf61d2591
--- /dev/null
+++ b/packages/cli/src/modules/agents/integrations/platforms/teams-integration.ts
@@ -0,0 +1,206 @@
+import type { RichCardComponentType } from '@n8n/api-types';
+import { Logger } from '@n8n/backend-common';
+import { Service } from '@n8n/di';
+import { UserError } from 'n8n-workflow';
+
+import { AgentRepository } from '../../repositories/agent.repository';
+import { createAdapterLogger } from '../adapter-logger';
+import { credentialField, requireCredentialField } from '../credential-fields';
+import {
+ AgentChatIntegration,
+ type AgentChannelPreconditionContext,
+ type AgentChatIntegrationContext,
+ type ActionDecisionMessageParams,
+} from '../agent-chat-integration';
+import { expandSelectsToButtons, type SuspendComponent } from '../component-mapper';
+import { assertCredentialNotClaimed } from '../credential-claim';
+import { loadTeamsAdapter } from '../esm-loader';
+import { resolveIntegrationActionDefinitions } from '../integration-tool-definitions';
+
+/** Pinned so a stray TEAMS_API_URL env var cannot redirect proactive sends. */
+const TEAMS_API_URL = 'https://smba.trafficmanager.net/teams';
+
+const GLOBAL_GRAPH_API_BASE_URL = 'https://graph.microsoft.com';
+
+/**
+ * A tenant ID is a GUID or a verified domain. The value reaches the Teams SDK,
+ * which interpolates it into a token URL path, so the shape is checked before
+ * it gets there.
+ *
+ * The domain form is matched per label rather than by alphabet: a value of `.`
+ * or `..` passes an alphabet check, and URL normalization then drops the tenant
+ * segment entirely. Two labels minimum, each starting and ending alphanumeric.
+ */
+const TENANT_ID_GUID = /^[0-9a-fA-F]{8}-([0-9a-fA-F]{4}-){3}[0-9a-fA-F]{12}$/;
+const TENANT_ID_DOMAIN =
+ /^[A-Za-z0-9]([A-Za-z0-9-]*[A-Za-z0-9])?(\.[A-Za-z0-9]([A-Za-z0-9-]*[A-Za-z0-9])?)+$/;
+
+/**
+ * Teams sits with Discord rather than Telegram on registration: the messaging
+ * endpoint is configured once in Azure Bot Service, so there is no API call to
+ * register or release it and no `onAfterConnect`/`onBeforeDisconnect` hook.
+ *
+ * This first slice targets 1:1 direct messages. Group chats and channels are not
+ * blocked, but they are untested: without RSC permissions Teams only delivers an
+ * @-mention there, so nothing arrives ambiently.
+ */
+@Service()
+export class TeamsIntegration extends AgentChatIntegration {
+ readonly type = 'teams';
+
+ readonly credentialTypes = ['microsoftEntraServicePrincipalApi'];
+
+ readonly displayLabel = 'Microsoft Teams';
+
+ readonly displayIcon = 'teams';
+
+ /** Hidden from the catalog and the add-trigger UI until the setup stepper ships. */
+ readonly internal = true;
+
+ readonly builderGuidance = {
+ capabilities: [
+ 'Receive Microsoft Teams direct messages as agent triggers.',
+ 'Respond in the same Microsoft Teams conversation.',
+ 'Render Adaptive Cards with buttons.',
+ ],
+ useIntegrationWhen: [
+ 'The agent should be chatted with from Microsoft Teams or act as a Teams bot.',
+ 'The agent needs to reply to Teams users in the same conversation context.',
+ ],
+ useNodeToolWhen: [
+ 'Microsoft Teams is only a backend API step and the agent does not need to be connected as a Teams chat surface.',
+ 'The Teams operation is performed by a non-Agent workflow, or the exact operation is not listed in the Agent integration capabilities.',
+ ],
+ };
+
+ readonly supportedComponents: readonly RichCardComponentType[] = [
+ 'section',
+ 'button',
+ 'divider',
+ 'fields',
+ 'image',
+ ];
+
+ readonly actionToolDefinitions = resolveIntegrationActionDefinitions(['respond', 'edit_message']);
+
+ readonly actionToolGuidance = [
+ 'For edit_message, pass the messageId returned by a previous Teams action or get_current_message_context. The current Teams conversation is selected automatically.',
+ ];
+
+ /**
+ * Teams acknowledges an Adaptive Card action by editing the card in place, so
+ * the answered card is settled rather than deleted.
+ */
+ readonly deleteActionMessageBeforeResume = false;
+
+ readonly disableStreaming = true;
+
+ constructor(
+ private readonly logger: Logger,
+ private readonly agentRepository: AgentRepository,
+ ) {
+ super();
+ }
+
+ async createAdapter(ctx: AgentChatIntegrationContext): Promise {
+ const { appId, appPassword, appTenantId } = this.extractBotCredentials(ctx.credential);
+ const { createTeamsAdapter } = await loadTeamsAdapter();
+
+ return createTeamsAdapter({
+ appId,
+ appPassword,
+ appTenantId,
+ apiUrl: TEAMS_API_URL,
+ // The Entra credential always carries a tenant ID, so a bot registered as
+ // multi-tenant is still driven single-tenant here. Nothing detects that
+ // mismatch: it surfaces as a token-mint failure on the first message.
+ appType: 'SingleTenant',
+ logger: createAdapterLogger(this.logger, '[TeamsAdapter]'),
+ });
+ }
+
+ async assertStartupPreconditions(ctx: AgentChannelPreconditionContext): Promise {
+ await assertCredentialNotClaimed(this.agentRepository, this.displayLabel, this.type, ctx);
+ }
+
+ async onBeforeConnect(ctx: AgentChatIntegrationContext): Promise {
+ await this.assertStartupPreconditions(ctx);
+
+ // Surface a bad credential at connect rather than at the first message.
+ this.extractBotCredentials(ctx.credential);
+ }
+
+ /**
+ * Adaptive Cards do render a select as `Input.ChoiceSet`, but the adapter
+ * submits one through a `__auto_submit` sentinel that fans every input out as
+ * its own action event. A suspended tool call expects a single action to
+ * resume it, so options become individual buttons instead.
+ */
+ normalizeComponents(components: SuspendComponent[]): SuspendComponent[] {
+ return expandSelectsToButtons(components);
+ }
+
+ formatActionDecisionMessage({
+ approved,
+ selectedLabel,
+ user,
+ }: ActionDecisionMessageParams): string {
+ const responder = user.fullName || user.userName || user.userId;
+ if (approved === undefined) {
+ return `✅ ${selectedLabel || 'Action'} selected by ${responder}`;
+ }
+ return approved ? `✅ Approved by ${responder}` : `🚫 Declined by ${responder}`;
+ }
+
+ /**
+ * The certificate check is ours to make: that mode stores no `clientSecret`,
+ * so passing it through would build an adapter with no secret and fail later
+ * with an opaque authentication error.
+ */
+ private extractBotCredentials(credential: Record): {
+ appId: string;
+ appPassword: string;
+ appTenantId: string;
+ } {
+ if (credentialField(credential, 'authentication') === 'certificate') {
+ throw new UserError(
+ 'Microsoft Teams channels cannot use certificate authentication. ' +
+ 'Switch the credential to Client Secret, or create a credential that uses one.',
+ );
+ }
+
+ const graphApiBaseUrl = credentialField(credential, 'graphApiBaseUrl');
+ if (graphApiBaseUrl && graphApiBaseUrl.replace(/\/+$/, '') !== GLOBAL_GRAPH_API_BASE_URL) {
+ throw new UserError(
+ 'Microsoft Teams channels only reach the global Microsoft cloud. ' +
+ "Set the credential's Microsoft Graph API base URL back to https://graph.microsoft.com.",
+ );
+ }
+
+ const appTenantId = requireCredentialField(
+ credential,
+ 'tenantId',
+ 'The Microsoft Teams credential is missing a Directory (tenant) ID. Copy it from the app registration overview in the Microsoft Entra admin center.',
+ );
+ if (!TENANT_ID_GUID.test(appTenantId) && !TENANT_ID_DOMAIN.test(appTenantId)) {
+ throw new UserError(
+ 'The Microsoft Teams credential has an invalid Directory (tenant) ID. ' +
+ 'Use the GUID from the app registration overview, or a verified domain such as contoso.onmicrosoft.com.',
+ );
+ }
+
+ return {
+ appId: requireCredentialField(
+ credential,
+ 'clientId',
+ 'The Microsoft Teams credential is missing an Application (client) ID. Copy it from the app registration overview in the Microsoft Entra admin center.',
+ ),
+ appPassword: requireCredentialField(
+ credential,
+ 'clientSecret',
+ 'The Microsoft Teams credential is missing a Client Secret. Create one under Certificates & secrets on the app registration.',
+ ),
+ appTenantId,
+ };
+ }
+}
diff --git a/packages/frontend/@n8n/design-system/src/components/N8nIcon/custom/teams.svg b/packages/frontend/@n8n/design-system/src/components/N8nIcon/custom/teams.svg
new file mode 100644
index 00000000000..98d3ab7326b
--- /dev/null
+++ b/packages/frontend/@n8n/design-system/src/components/N8nIcon/custom/teams.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/packages/frontend/@n8n/design-system/src/components/N8nIcon/icons.ts b/packages/frontend/@n8n/design-system/src/components/N8nIcon/icons.ts
index aaeacf15fb7..c5bbf8eb6f5 100644
--- a/packages/frontend/@n8n/design-system/src/components/N8nIcon/icons.ts
+++ b/packages/frontend/@n8n/design-system/src/components/N8nIcon/icons.ts
@@ -34,6 +34,7 @@ import StatusNew from './custom/status-new.svg';
import StatusUnknown from './custom/status-unknown.svg';
import StatusWaiting from './custom/status-waiting.svg';
import StatusWarning from './custom/status-warning.svg';
+import Teams from './custom/teams.svg';
import Telegram from './custom/telegram.svg';
import Text from './custom/text.svg';
import Toolbox from './custom/toolbox.svg';
@@ -315,6 +316,7 @@ export const deprecatedIconSet = {
linear: Linear,
telegram: Telegram,
discord: Discord,
+ teams: Teams,
spinner: Spinner,
xmark: IconLucideX,
mcp: Mcp,
@@ -536,6 +538,7 @@ export const updatedIconSet = {
linear: Linear,
telegram: Telegram,
discord: Discord,
+ teams: Teams,
spinner: Spinner,
'node-dirty': NodeDirty,
'node-ellipsis': NodeEllipsis,
diff --git a/packages/frontend/editor-ui/src/features/agents/__tests__/AgentPreviewMoreMenu.test.ts b/packages/frontend/editor-ui/src/features/agents/__tests__/AgentPreviewMoreMenu.test.ts
index a69eae7223f..35c7ddd70b9 100644
--- a/packages/frontend/editor-ui/src/features/agents/__tests__/AgentPreviewMoreMenu.test.ts
+++ b/packages/frontend/editor-ui/src/features/agents/__tests__/AgentPreviewMoreMenu.test.ts
@@ -384,6 +384,7 @@ describe('AgentPreviewMoreMenu', function describeMenu() {
['telegram', 'Telegram', 'telegram'],
['linear', 'Linear', 'linear'],
['discord', 'Discord', 'discord'],
+ ['teams', 'Teams', 'teams'],
['mcp', 'Mcp', 'mcp'],
['workflow', 'Workflow', 'workflow'],
['webhook', 'Webhook', 'webhook'],
diff --git a/packages/frontend/editor-ui/src/features/agents/__tests__/AgentSessionsListView.spec.ts b/packages/frontend/editor-ui/src/features/agents/__tests__/AgentSessionsListView.spec.ts
index a1d619a7afd..e6bb2791c21 100644
--- a/packages/frontend/editor-ui/src/features/agents/__tests__/AgentSessionsListView.spec.ts
+++ b/packages/frontend/editor-ui/src/features/agents/__tests__/AgentSessionsListView.spec.ts
@@ -429,7 +429,8 @@ describe('AgentSessionsListView', () => {
[{ parentThreadId: 'parent-1', source: 'slack' }, 'Sub-agent', 'bot'],
[{ source: 'task' }, 'Schedule', 'clock'],
[{ taskId: 'task-1', source: 'slack' }, 'Schedule', 'clock'],
- [{ source: 'teams' }, 'Teams', 'plug'],
+ [{ source: 'teams' }, 'Teams', 'teams'],
+ [{ source: 'future-channel' }, 'Future-channel', 'plug'],
[{ source: ' Slack ' }, 'Slack', 'slack'],
] as const)(
'renders origin chip for %j as %s with the %s icon',
diff --git a/packages/frontend/editor-ui/src/features/agents/channels/registry.test.ts b/packages/frontend/editor-ui/src/features/agents/channels/registry.test.ts
index 32825182eee..61bc8b702a9 100644
--- a/packages/frontend/editor-ui/src/features/agents/channels/registry.test.ts
+++ b/packages/frontend/editor-ui/src/features/agents/channels/registry.test.ts
@@ -23,6 +23,8 @@ describe('agent channel platform registry', () => {
it('narrows registered platform keys', () => {
expect(isRegisteredAgentChannelPlatform('slack')).toBe(true);
expect(isRegisteredAgentChannelPlatform('future-channel')).toBe(false);
+ // Teams ships without a setup stepper, so it rides the fallback view.
+ expect(isRegisteredAgentChannelPlatform('teams')).toBe(false);
});
it('derives Slack list metadata from its local runtime state', () => {
diff --git a/packages/frontend/editor-ui/src/features/agents/components/AgentPreviewMoreMenu.vue b/packages/frontend/editor-ui/src/features/agents/components/AgentPreviewMoreMenu.vue
index d85424adfaa..120059976b4 100644
--- a/packages/frontend/editor-ui/src/features/agents/components/AgentPreviewMoreMenu.vue
+++ b/packages/frontend/editor-ui/src/features/agents/components/AgentPreviewMoreMenu.vue
@@ -132,6 +132,7 @@ const triggerIcon = computed(() => {
case 'telegram':
case 'linear':
case 'discord':
+ case 'teams':
case 'mcp':
case 'workflow':
case 'webhook':
diff --git a/packages/frontend/editor-ui/src/features/agents/views/AgentSessionsListView.vue b/packages/frontend/editor-ui/src/features/agents/views/AgentSessionsListView.vue
index 5f4954983e1..7ff638cf752 100644
--- a/packages/frontend/editor-ui/src/features/agents/views/AgentSessionsListView.vue
+++ b/packages/frontend/editor-ui/src/features/agents/views/AgentSessionsListView.vue
@@ -155,6 +155,7 @@ function originPresentation(thread: AgentExecutionThread): OriginPresentation {
case 'telegram':
case 'linear':
case 'discord':
+ case 'teams':
return { icon: source, label: source.charAt(0).toUpperCase() + source.slice(1) };
case 'chat':
case 'n8n_chat':
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index fd4ed56f833..56daeb49215 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -236,6 +236,9 @@ catalogs:
'@chat-adapter/state-memory':
specifier: 4.37.0
version: 4.37.0
+ '@chat-adapter/teams':
+ specifier: 4.37.0
+ version: 4.37.0
'@chat-adapter/telegram':
specifier: 4.37.0
version: 4.37.0
@@ -4399,6 +4402,9 @@ importers:
'@chat-adapter/state-memory':
specifier: 'catalog:'
version: 4.37.0(ai@7.0.74(zod@3.25.76))(supports-color@8.1.1)(zod@3.25.76)
+ '@chat-adapter/teams':
+ specifier: 'catalog:'
+ version: 4.37.0(ai@7.0.74(zod@3.25.76))(debug@4.4.3(supports-color@8.1.1))(supports-color@8.1.1)(zod@3.25.76)
'@chat-adapter/telegram':
specifier: 'catalog:'
version: 4.37.0(ai@7.0.74(zod@3.25.76))(supports-color@8.1.1)(zod@3.25.76)
@@ -8749,6 +8755,10 @@ packages:
resolution: {integrity: sha512-shSDU7Ioecya+Aob5xliW9IGq1Ui8y4EVSdWGyI1Gbm4Vg61WpP95LuzcY214/wEjSn6w4PZYD4/iVldErHayQ==}
engines: {node: '>=0.8.0'}
+ '@azure/msal-common@16.13.0':
+ resolution: {integrity: sha512-rOAy0KUcyBbdwVJ+f3uPpthXatFLLZN+/KWAsTLzk1aB23Xl9DRmmXYwSvBFOZyXj4jUQQ5FKxxRkhAFW1fOow==}
+ engines: {node: '>=0.8.0'}
+
'@azure/msal-common@16.5.2':
resolution: {integrity: sha512-GkDEL6TYo3HgT3UuqakdgE9PZfc1hMki6+Hwgy1uddb/EauvAKfu85vVhuofRSo22D1xTnWt8Ucwfg4vSCVwvA==}
engines: {node: '>=0.8.0'}
@@ -8761,6 +8771,10 @@ packages:
resolution: {integrity: sha512-ObTeMoNPmq19X3z40et9Xvs4ZoWVeJg43PZMRLG5iwVL+2nCtAerG3YTDItqPp1CfXNwmCXBbg8jn1DOx65c3g==}
engines: {node: '>=20'}
+ '@azure/msal-node@5.6.0':
+ resolution: {integrity: sha512-uFY9NxrWHw8PwZx7gAX6PDn+9vdfS05+levc/kwkx77IkjfaldnQbbcQzzDIZ5Hq5Zdr6/z92oAIoRWKp6MnOA==}
+ engines: {node: '>=20'}
+
'@azure/search-documents@12.1.0':
resolution: {integrity: sha512-IzD+hfqGqFtXymHXm4RzrZW2MsSH2M7RLmZsKaKVi7SUxbeYTUeX+ALk8gVzkM8ykb7EzlDLWCNErKfAa57rYQ==}
engines: {node: '>=18.0.0'}
@@ -9559,6 +9573,10 @@ packages:
resolution: {integrity: sha512-dI3dk+PUxkL1q5FZ7fn7T9nq9SIBnoovw3moTIl8xwuB4kWRNsvFa5CM7H0fGeaDPeyJPR+PQ+rUKPGKumMKTg==}
engines: {node: '>=20'}
+ '@chat-adapter/teams@4.37.0':
+ resolution: {integrity: sha512-vlMooVt6jYWd13LLR4dJ+EzU/z1EuXHo+jHCDWrQvsP9syYcllnJEu5Qd6cCma5nA7Ri6OEBGNJhCFWGa+Hctg==}
+ engines: {node: '>=20'}
+
'@chat-adapter/telegram@4.37.0':
resolution: {integrity: sha512-A/lBDH7QUdGzxn+aBUyfg0Dp3uO9AW04Q2iXd4SwAz1t14wVb/gQ+KeI1QB287thgx+uLfr13f0/5Ss58ET11w==}
engines: {node: '>=20'}
@@ -11208,6 +11226,30 @@ packages:
resolution: {integrity: sha512-m3I5uAwE05orsu3D1AGyisX5KxsgVXB+U4bWOOaX/Z7Ftp/2Cy41qsNhO6LPvSxHBaapyser5dVorF1t5M6tig==}
hasBin: true
+ '@microsoft/teams.api@2.0.16':
+ resolution: {integrity: sha512-ds2SK6JvAcDL1EfVYWJ1Cmvyytm3CIfdPZS7CxYPvsebhfnMNmvxQlDFuh0Hh0hmjLxIJtycqBddMVulZITs1w==}
+ engines: {node: '>=20'}
+
+ '@microsoft/teams.apps@2.0.16':
+ resolution: {integrity: sha512-LwNy2MgP92ZZziGs/8f+17xttjgdzxgP2IYQCwNQ5ENoBFoQewRb53mARxeLEBhKtVfAtDG2yKHAqRuQvvltEg==}
+ engines: {node: '>=20'}
+
+ '@microsoft/teams.cards@2.0.16':
+ resolution: {integrity: sha512-hOt+2m6oKI5LdASgsQmGB8yOMs/DTMlypgnQDf9dKWFlDiaZ6fkOy3zuNvVRdT3qZ70EOlq1MlhlT8AZxBbcRg==}
+ engines: {node: '>=20'}
+
+ '@microsoft/teams.common@2.0.16':
+ resolution: {integrity: sha512-Z/D0RLTNzkcgGNOdfCzq591UUgtuWIaSu6bbieDxIrLERcu6qI3O6EeeMQLCu/+xkxJrjPJny97CtQEhfLwY8Q==}
+ engines: {node: '>=20'}
+
+ '@microsoft/teams.graph-endpoints@2.0.16':
+ resolution: {integrity: sha512-yNqxdr/elYyzL0xcrGF5/muZniFLbel4QIQgGalwyropRzupasBuaWmIgFU4RVOG9i1dlvRAIq3sLj8E2OhVQQ==}
+ engines: {node: '>=20'}
+
+ '@microsoft/teams.graph@2.0.16':
+ resolution: {integrity: sha512-tEdVCj76gEdgvxoYkBH3li+mr8Tfe94pp1cSOUMQNwOnsuKYvretKMZfAnt9adJCgGzGH2MYZeDDtgwmOBufGQ==}
+ engines: {node: '>=20'}
+
'@microsoft/tsdoc-config@0.17.1':
resolution: {integrity: sha512-UtjIFe0C6oYgTnad4q1QP4qXwLhe6tIpNTRStJ2RZEPIkqQPREAwE5spzVxsdn9UaEMUqhh0AqSx3X4nWAKXWw==}
@@ -19546,6 +19588,10 @@ packages:
jwa@2.0.1:
resolution: {integrity: sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==}
+ jwks-rsa@3.2.2:
+ resolution: {integrity: sha512-BqTyEDV+lS8F2trk3A+qJnxV5Q9EqKCBJOPti3W97r7qTympCZjb7h2X6f2kc+0K3rsSTY1/6YG2eaXKoj497w==}
+ engines: {node: '>=14'}
+
jwks-rsa@4.0.1:
resolution: {integrity: sha512-poXwUA8S4cP9P5N8tZS3xnUDJH8WmwSGfKK9gIaRPdjLHyJtd9iX/cngX9CUIe0Caof5JhK2EbN7N5lnnaf9NA==}
engines: {node: ^20.19.0 || ^22.12.0 || >= 23.0.0}
@@ -19553,6 +19599,10 @@ packages:
jws@4.0.1:
resolution: {integrity: sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==}
+ jwt-decode@4.0.0:
+ resolution: {integrity: sha512-+KJGIyHgkGuIq3IEBNftfhW/LfWhXUIY6OmyVWjliu5KH1y0fw7VQ8YndE2O4qZdMSd9SqbnC8GOcZEy0Om7sA==}
+ engines: {node: '>=18'}
+
kafkajs@2.2.4:
resolution: {integrity: sha512-j/YeapB1vfPT2iOIUn/vxdyKEuhuY2PxMBvf5JWux6iSaukAccrMtXEY/Lb7OvavDhOWME589bpLrEdnVHjfjA==}
engines: {node: '>=14.0.0'}
@@ -19995,6 +20045,9 @@ packages:
resolution: {integrity: sha512-ERJq3FOzJTxBbFjZ7iDs+NiK4VI9Wz+RdrrAB8dio1oV+YvdPzUEE4QNiT2VD51DkIbCYRUUzCRkssXCHqSnKQ==}
engines: {node: 14 || >=16.14}
+ lru-memoizer@2.3.0:
+ resolution: {integrity: sha512-GXn7gyHAMhO13WSKrIiNfztwxodVsP8IoZ3XfrJV4yH2x0/OeTO/FIaAHTY5YekdGgW94njfuKmyyt1E0mR6Ug==}
+
lru-memoizer@3.0.0:
resolution: {integrity: sha512-m83w/cYXLdUIboKSPxzPAGfYnk+vqeDYXuoSrQRw1q+yVEd8IXhvMufN8Q5TIPe7e2jyX4SRNrDJI2Skw1yznQ==}
@@ -26754,6 +26807,8 @@ snapshots:
'@azure/msal-common@15.13.3': {}
+ '@azure/msal-common@16.13.0': {}
+
'@azure/msal-common@16.5.2': {}
'@azure/msal-node@3.8.4':
@@ -26767,6 +26822,11 @@ snapshots:
'@azure/msal-common': 16.5.2
jsonwebtoken: 9.0.3
+ '@azure/msal-node@5.6.0':
+ dependencies:
+ '@azure/msal-common': 16.13.0
+ jsonwebtoken: 9.0.3
+
'@azure/search-documents@12.1.0(supports-color@8.1.1)':
dependencies:
'@azure/core-auth': 1.10.1(supports-color@8.1.1)
@@ -27834,6 +27894,21 @@ snapshots:
- workflow
- zod
+ '@chat-adapter/teams@4.37.0(ai@7.0.74(zod@3.25.76))(debug@4.4.3(supports-color@8.1.1))(supports-color@8.1.1)(zod@3.25.76)':
+ dependencies:
+ '@chat-adapter/shared': 4.37.0(ai@7.0.74(zod@3.25.76))(supports-color@8.1.1)(zod@3.25.76)
+ '@microsoft/teams.api': 2.0.16(debug@4.4.3(supports-color@8.1.1))(supports-color@8.1.1)
+ '@microsoft/teams.apps': 2.0.16(debug@4.4.3(supports-color@8.1.1))(supports-color@8.1.1)
+ '@microsoft/teams.cards': 2.0.16
+ '@microsoft/teams.graph-endpoints': 2.0.16
+ chat: 4.37.0(ai@7.0.74(zod@3.25.76))(supports-color@8.1.1)(zod@3.25.76)
+ transitivePeerDependencies:
+ - ai
+ - debug
+ - supports-color
+ - workflow
+ - zod
+
'@chat-adapter/telegram@4.37.0(ai@7.0.74(zod@3.25.76))(supports-color@8.1.1)(zod@3.25.76)':
dependencies:
'@chat-adapter/shared': 4.37.0(ai@7.0.74(zod@3.25.76))(supports-color@8.1.1)(zod@3.25.76)
@@ -29791,6 +29866,51 @@ snapshots:
- '@types/node'
optional: true
+ '@microsoft/teams.api@2.0.16(debug@4.4.3(supports-color@8.1.1))(supports-color@8.1.1)':
+ dependencies:
+ '@microsoft/teams.cards': 2.0.16
+ '@microsoft/teams.common': 2.0.16(debug@4.4.3(supports-color@8.1.1))(supports-color@8.1.1)
+ jwt-decode: 4.0.0
+ qs: 6.15.2
+ transitivePeerDependencies:
+ - debug
+ - supports-color
+
+ '@microsoft/teams.apps@2.0.16(debug@4.4.3(supports-color@8.1.1))(supports-color@8.1.1)':
+ dependencies:
+ '@azure/msal-node': 5.6.0
+ '@microsoft/teams.api': 2.0.16(debug@4.4.3(supports-color@8.1.1))(supports-color@8.1.1)
+ '@microsoft/teams.common': 2.0.16(debug@4.4.3(supports-color@8.1.1))(supports-color@8.1.1)
+ '@microsoft/teams.graph': 2.0.16(debug@4.4.3(supports-color@8.1.1))(supports-color@8.1.1)
+ axios: 1.18.0(patch_hash=149e256a2a7b632497650b32816716ced972ab02a5ab00fbd8a5158a51722c49)(debug@4.4.3(supports-color@8.1.1))(supports-color@8.1.1)
+ cors: 2.8.5
+ express: 5.2.1(supports-color@8.1.1)
+ jsonwebtoken: 9.0.3
+ jwks-rsa: 3.2.2(supports-color@8.1.1)
+ reflect-metadata: 0.2.2
+ transitivePeerDependencies:
+ - debug
+ - supports-color
+
+ '@microsoft/teams.cards@2.0.16': {}
+
+ '@microsoft/teams.common@2.0.16(debug@4.4.3(supports-color@8.1.1))(supports-color@8.1.1)':
+ dependencies:
+ axios: 1.18.0(patch_hash=149e256a2a7b632497650b32816716ced972ab02a5ab00fbd8a5158a51722c49)(debug@4.4.3(supports-color@8.1.1))(supports-color@8.1.1)
+ transitivePeerDependencies:
+ - debug
+ - supports-color
+
+ '@microsoft/teams.graph-endpoints@2.0.16': {}
+
+ '@microsoft/teams.graph@2.0.16(debug@4.4.3(supports-color@8.1.1))(supports-color@8.1.1)':
+ dependencies:
+ '@microsoft/teams.common': 2.0.16(debug@4.4.3(supports-color@8.1.1))(supports-color@8.1.1)
+ qs: 6.15.2
+ transitivePeerDependencies:
+ - debug
+ - supports-color
+
'@microsoft/tsdoc-config@0.17.1':
dependencies:
'@microsoft/tsdoc': 0.15.1
@@ -39217,6 +39337,16 @@ snapshots:
ecdsa-sig-formatter: 1.0.11
safe-buffer: 5.2.1
+ jwks-rsa@3.2.2(supports-color@8.1.1):
+ dependencies:
+ '@types/jsonwebtoken': 9.0.10
+ debug: 4.4.3(supports-color@8.1.1)
+ jose: 6.2.9
+ limiter: 1.1.5
+ lru-memoizer: 2.3.0
+ transitivePeerDependencies:
+ - supports-color
+
jwks-rsa@4.0.1(supports-color@8.1.1):
dependencies:
'@types/jsonwebtoken': 9.0.10
@@ -39232,6 +39362,8 @@ snapshots:
jwa: 2.0.1
safe-buffer: 5.2.1
+ jwt-decode@4.0.0: {}
+
kafkajs@2.2.4: {}
katex@0.18.2:
@@ -39656,6 +39788,11 @@ snapshots:
lru-cache@9.1.2: {}
+ lru-memoizer@2.3.0:
+ dependencies:
+ lodash.clonedeep: 4.5.0
+ lru-cache: 6.0.0
+
lru-memoizer@3.0.0:
dependencies:
lodash.clonedeep: 4.5.0
diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml
index 87c17d7b025..6092d042c61 100644
--- a/pnpm-workspace.yaml
+++ b/pnpm-workspace.yaml
@@ -78,6 +78,7 @@ catalog:
'@chat-adapter/linear': 4.37.0
'@chat-adapter/slack': 4.37.0
'@chat-adapter/state-memory': 4.37.0
+ '@chat-adapter/teams': 4.37.0
'@chat-adapter/telegram': 4.37.0
'@codemirror/autocomplete': 6.20.0
'@codemirror/commands': 6.10.1