메뉴
HN
Hacker News 38일 전

직접 만든 AI 에이전트를 MS Teams로 가져오기

IMP
6/10
핵심 요약

이미 구축된 다양한 AI 에이전트(Slack 봇, LangChain 체인 등)를 마이크로소프트 Teams 환경으로 쉽게 통합하는 방법을 소개합니다. Teams TypeScript SDK의 HTTP 서버 어댑터 패턴을 활용해 기존 코드베이스를 그대로 유지하면서 Teams 메시징 엔드포인트(/api/messages)만 추가할 수 있습니다. 이를 통해 개발자는 에이전트 로직을 중복으로 작성할 필요 없이, 실무자들이 주로 활용하는 Teams 환경에 즉각적으로 AI 기능을 투입할 수 있습니다.

번역된 본문

당신은 이미 에이전트를 구축했습니다. 그리고 그것은 어딘가에 존재합니다. LangChain 체인일 수도, Azure AI Foundry 배포일 수도, 혹은 Slack 봇일 수도 있습니다. 하지만 실제 사용자들은 Teams 안에서 생활합니다. 대부분의 기업 업무가 Teams에서 이루어지며, 의사결정이 이루어지고, 고객의 문의에 답하며, 프로젝트가 진행됩니다. Teams에 특화된 무언가를 새로 구축하기 전에, 당신의 에이전트를 바로 그 업무 환경에 통합하는 것은 충분히 가치 있는 일입니다.

이 문제는 Teams TypeScript SDK의 단일 패턴인 'HTTP 서버 어댑터(HTTP server adapter)'로 해결할 수 있습니다. 이 어댑터를 기존 HTTP 서버에 연결하면 메시징 엔드포인트를 등록해 주며, 기존 서버는 그대로 계속 실행됩니다. 아래의 시나리오들은 세 가지 다른 시작점(Slack 봇, LangChain 체인, Azure AI Foundry 에이전트)을 다룹니다. 또한 이 SDK는 개발자가 신경 쓰고 싶지 않은 부분들도 알아서 처리해 줍니다. 들어오는 모든 요청이 합법적으로 Teams로부터 온 것인지 핸들러를 호출하기 전에 검증하며, 메시지를 알맞은 이벤트 핸들러로 자동 라우팅합니다.

이 패턴 (The Pattern)

이 글의 모든 예시는 동일한 세 단계 구조를 따릅니다:

import { App as TeamsApp, ExpressAdapter } from '@microsoft/teams.apps';

const adapter = new ExpressAdapter(expressApp); // 1. 서버를 래핑(Wrap)합니다.
const teamsApp = new TeamsApp({ httpServerAdapter: adapter }); // 2. 앱을 생성합니다.

teamsApp.on('message', async ({ send, activity }) => { // 3. 메시지를 처리합니다.
  await send(/* 에이전트의 응답 */);
});

await teamsApp.initialize(); // 당신의 서버에 POST /api/messages 엔드포인트를 등록합니다.

SDK는 기존 Express 앱에 POST /api/messages 라우트를 주입합니다. /api/messages는 Teams가 봇에게 메시지를 전달하기 위해 사용하는 잘 알려진 엔드포인트이자, HTTP 서버가 갖춰야 할 Teams 형태의 인터페이스입니다. 서버의 주도권은 당신에게 그대로 남아있으며, Teams SDK는 단지 그 하나의 엔드포인트만 추가할 뿐입니다.

시나리오 1: Slack 봇 (Slack Bot)

Bolt(또는 웹 서비스로 배포된 다른 종류의 봇)으로 구축한 Slack 봇이 있습니다. 팀에서 Slack과 Teams를 모두 사용하고 있으며, 두 개의 코드베이스를 유지하는 대신 동일한 Express 서버에서 두 가지를 모두 실행하고 싶을 것입니다. ExpressReceiver를 사용하면 Bolt가 서버를 소유하는 대신 당신의 Express 앱에 마운트되도록 할 수 있습니다. Teams SDK도 마찬가지 방식을 사용하므로, 두 플랫폼이 동일한 프로세스를 공유하게 됩니다.

slack-app.ts: 기존의 Slack 로직 (그대로 유지)

import { App as BoltApp, ExpressReceiver } from '@slack/bolt';
import type { Express } from 'express';

export function mountSlack(expressApp: Express) {
  const slackReceiver = new ExpressReceiver({
    signingSecret: process.env.SLACK_SIGNING_SECRET,
    app: expressApp,
    endpoints: { events: '/slack/events' },
  });

  const slackApp = new BoltApp({
    token: process.env.SLACK_BOT_TOKEN,
    receiver: slackReceiver,
  });

  slackApp.message('hello', async ({ say }) => {
    await say('Hey! Caught you on Slack.');
  });
}

teams-app.ts:

import express from 'express';
import { App as TeamsApp, ExpressAdapter } from '@microsoft/teams.apps';
import { mountSlack } from './slack-app';

const expressApp = express();
mountSlack(expressApp);

// Teams는 /api/messages에 마운트됩니다.
const adapter = new ExpressAdapter(expressApp);
const teamsApp = new TeamsApp({ httpServerAdapter: adapter });

teamsApp.on('message', async ({ send, activity }) => {
  await send(`Hey ${activity.from.name}! You said: "${activity.text}"`);
});

export { expressApp, teamsApp };

두 플랫폼 모두 동일한 프로세스 내에서 실행됩니다. Slack은 /slack/events에 도달하고, Teams는 /api/messages에 도달합니다. 공유되는 에이전트 로직(LLM 호출, 데이터베이스 조회, 비즈니스 규칙 등)은 두 핸들러가 모두 호출하는 일반 함수 형태로 존재하게 됩니다.

시나리오 2: LangChain

당신은 LangChain 체인을 가지고 있으며, Teams 사용자들이 그것과 대화하기를 원합니다.

chain.ts: 기존의 LangChain 로직 (그대로 유지)

import { ChatOpenAI } from '@langchain/openai';
import { ChatPromptTemplate } from '@langchain/core/prompts';
import { StringOutputParser } from '@langchain/core/output_parsers';

let _chain: ReturnType<typeof buildChain
원문 보기
원문 보기 (영어)
You&#x27;ve already built the agent. It lives somewhere: a LangChain chain, an Azure Foundry deployment, a Slack bot. Your users live in Teams. Teams is where most enterprise work happens: decisions get made, customers get answered, and projects move forward there. Getting your agent into that context, before you build anything Teams-specific, is already worth doing. It comes down to one pattern in the Teams TypeScript SDK: the HTTP server adapter . You point it at your HTTP server, it registers a messaging endpoint, and your existing server keeps running as-is. The scenarios below cover three different starting points: a Slack bot, a LangChain chain, and an Azure Foundry agent. The SDK also handles the parts you don&#x27;t want to think about: it verifies every incoming request is legitimately from Teams before invoking your handler, and routes messages to the right event handlers automatically. The Pattern ​ Every example in this post uses the same three-step shape: import { App as TeamsApp , ExpressAdapter } from &#x27;@microsoft/teams.apps&#x27; ; const adapter = new ExpressAdapter ( expressApp ) ; // 1. wrap your server const teamsApp = new TeamsApp ( { httpServerAdapter : adapter } ) ; // 2. create the app teamsApp . on ( &#x27;message&#x27; , async ( { send , activity } ) => { // 3. handle messages await send ( /* your agent&#x27;s response */ ) ; } ) ; await teamsApp . initialize ( ) ; // registers POST /api/messages on your server The SDK injects a POST /api/messages route into your existing Express app. /api/messages is the well-known endpoint Teams uses to deliver messages to your bot, the Teams-shaped interface your HTTP server needs to have. Your server stays yours; the Teams SDK just adds that one endpoint. Scenario 1: Slack Bot ​ You have a Slack bot built with Bolt (or any other kind of bot deployed as a web service). Your team uses both Slack and Teams. Rather than maintaining two codebases, run both on the same Express server. ExpressReceiver lets Bolt mount onto your Express app instead of owning the server. The Teams SDK does the same thing, so both platforms share the same process. slack-app.ts : existing Slack logic, untouched import { App as BoltApp , ExpressReceiver } from &#x27;@slack/bolt&#x27; ; import type { Express } from &#x27;express&#x27; ; export function mountSlack ( expressApp : Express ) { const slackReceiver = new ExpressReceiver ( { signingSecret : process . env . SLACK_SIGNING_SECRET , app : expressApp , endpoints : { events : &#x27;/slack/events&#x27; } , } ) ; const slackApp = new BoltApp ( { token : process . env . SLACK_BOT_TOKEN , receiver : slackReceiver , } ) ; slackApp . message ( &#x27;hello&#x27; , async ( { say } ) => { await say ( &#x27;Hey! Caught you on Slack.&#x27; ) ; } ) ; } teams-app.ts : import express from &#x27;express&#x27; ; import { App as TeamsApp , ExpressAdapter } from &#x27;@microsoft/teams.apps&#x27; ; import { mountSlack } from &#x27;./slack-app&#x27; ; const expressApp = express ( ) ; mountSlack ( expressApp ) ; // Teams mounts at /api/messages const adapter = new ExpressAdapter ( expressApp ) ; const teamsApp = new TeamsApp ( { httpServerAdapter : adapter } ) ; teamsApp . on ( &#x27;message&#x27; , async ( { send , activity } ) => { await send ( ` Hey ${ activity . from . name } ! You said: " ${ activity . text } " ` ) ; } ) ; export { expressApp , teamsApp } ; Both platforms run in the same process. Slack hits /slack/events , Teams hits /api/messages , and any shared agent logic (LLM calls, database lookups, business rules) lives in plain functions that both handlers call. Scenario 2: LangChain ​ You have a LangChain chain. You want Teams users to talk to it. chain.ts : existing LangChain logic, untouched import { ChatOpenAI } from &#x27;@langchain/openai&#x27; ; import { ChatPromptTemplate } from &#x27;@langchain/core/prompts&#x27; ; import { StringOutputParser } from &#x27;@langchain/core/output_parsers&#x27; ; let _chain : ReturnType < typeof buildChain > | null = null ; function buildChain ( ) { const prompt = ChatPromptTemplate . fromMessages ( [ [ &#x27;system&#x27; , &#x27;You are a helpful assistant embedded in Microsoft Teams. Be concise.&#x27; ] , [ &#x27;human&#x27; , &#x27;{input}&#x27; ] , ] ) ; return prompt . pipe ( new ChatOpenAI ( { model : &#x27;gpt-4o-mini&#x27; } ) ) . pipe ( new StringOutputParser ( ) ) ; } export function getChain ( ) { if ( ! _chain ) _chain = buildChain ( ) ; return _chain ; } teams-app.ts (the bridge): import express from &#x27;express&#x27; ; import { App as TeamsApp , ExpressAdapter } from &#x27;@microsoft/teams.apps&#x27; ; import { getChain } from &#x27;./chain&#x27; ; const expressApp = express ( ) ; const adapter = new ExpressAdapter ( expressApp ) ; const teamsApp = new TeamsApp ( { httpServerAdapter : adapter } ) ; teamsApp . on ( &#x27;message&#x27; , async ( { send , activity } ) => { await send ( { type : &#x27;typing&#x27; } ) ; // pass the Teams message to LangChain const reply = await getChain ( ) . invoke ( { input : activity . text ?? &#x27;&#x27; } ) ; await send ( reply ) ; } ) ; export { expressApp , teamsApp } ; index.ts (start it): import &#x27;dotenv/config&#x27; ; import http from &#x27;http&#x27; ; import { expressApp , teamsApp } from &#x27;./teams-app&#x27; ; await teamsApp . initialize ( ) ; http . createServer ( expressApp ) . listen ( 3978 ) ; Your chain runs on every message. The typing indicator fires before the LLM responds so users know something&#x27;s happening. Scenario 3: Azure AI Foundry ​ You have an agent deployed in Azure AI Foundry. The Teams SDK gives you the message; you forward it to Foundry and relay the reply. foundry-agent.ts import { AIProjectClient } from &#x27;@azure/ai-projects&#x27; ; import { DefaultAzureCredential } from &#x27;@azure/identity&#x27; ; let _client : AIProjectClient | null = null ; function getClient ( ) { if ( ! _client ) { _client = AIProjectClient . fromEndpoint ( process . env . AZURE_AI_FOUNDRY_ENDPOINT ! , new DefaultAzureCredential ( ) , ) ; } return _client ; } export async function askFoundryAgent ( userMessage : string ) : Promise < string > { const client = getClient ( ) ; const thread = await client . agents . threads . create ( ) ; await client . agents . messages . create ( thread . id , &#x27;user&#x27; , userMessage ) ; const run = await client . agents . runs . createAndPoll ( thread . id , process . env . AZURE_AGENT_ID ! , ) ; if ( run . status !== &#x27;completed&#x27; ) throw new Error ( ` Run ended: ${ run . status } ` ) ; const messages = client . agents . messages . list ( thread . id ) ; for await ( const msg of messages ) { if ( msg . role === &#x27;assistant&#x27; ) { return msg . content . filter ( ( c ) : c is { type : &#x27;text&#x27; ; text : { value : string } } => c . type === &#x27;text&#x27; ) . map ( ( c ) => c . text . value ) . join ( &#x27;&#x27; ) ; } } return &#x27;No response from agent.&#x27; ; } teams-app.ts : import express from &#x27;express&#x27; ; import { App as TeamsApp , ExpressAdapter } from &#x27;@microsoft/teams.apps&#x27; ; import { askFoundryAgent } from &#x27;./foundry-agent&#x27; ; const expressApp = express ( ) ; const adapter = new ExpressAdapter ( expressApp ) ; const teamsApp = new TeamsApp ( { httpServerAdapter : adapter } ) ; teamsApp . on ( &#x27;message&#x27; , async ( { send , activity } ) => { // pass the Teams message to Foundry const reply = await askFoundryAgent ( activity . text ?? &#x27;&#x27; ) ; await send ( reply ) ; } ) ; export { expressApp , teamsApp } ; Python SDK A Python SDK is also available. The same three-step pattern applies with FastAPI and other ASGI frameworks. Show Python equivalent from fastapi import FastAPI from microsoft_teams . apps import App , FastAPIAdapter fastapi_app = FastAPI ( ) adapter = FastAPIAdapter ( app = fastapi_app ) # 1. wrap your server teams_app = App ( http_server_adapter = adapter ) # 2. create the app @teams_app . on_message async def handle_message ( ctx