AWS Builder Center: Learn, Build and Connect with builders in the AWS community
AWS Builder Center is the official home for builders on AWS. Share and read what others are working on, follow people who inspire you, explore training and workshops, and find tools to support what you're building.
Microsoft Teams App - DevOps Agent Bi-directional Integration
You DM the bot in Teams (or @mention it in a channel). DevOps Agent responds directly in the conversation. If it kicks off an investigation, the results come back in the same thread when it's done. Follow-up questions maintain context. Multi-space routing and registration for channels.
Microsoft Teams App - DevOps Agent Bi-directional Integration
I got tired of jumping between the AWS console and DevOps Agent every time something broke. So I built a Teams bot that bridges the gap: type a message in Teams, DevOps Agent investigates, and the results come back in the same thread. You can ask follow-up questions right there — no context switching required.
The whole thing is three Lambda functions, an API Gateway, one S3 bucket, and is cost effective. Here's exactly how to build it yourself.
What It Does
You DM the bot in Teams (or @mention it in a channel). DevOps Agent responds directly in the conversation. If it kicks off an investigation, the results come back in the same thread when it's done. Follow-up questions maintain context. Multi-space routing and registration for channels.
Teams Chat with DevOps Agent:
🧑 "hello, can you check my Lambda error rates?"
🤖 "I'll investigate Lambda functions in your account for error
patterns. Looking at CloudWatch metrics, recent deployments,
and log groups..."
🤖 "✅ Investigation Complete
Root cause: SpringClean Lambda alarm threshold (5,000ms) is
below normal 'default' mode duration (~44s). The alarm fires
on every invocation — this is a misconfigured alarm, not an
application issue.
Recommendation: Raise threshold to 60,000ms."
🧑 "Are there any code bugs in that function?"
🤖 "Yes — SNSControlResource.py line 51 has a TypeError
('string indices must be integers') when parsing SNS policy
data. The function completes but the SNS scanning thread fails."
No special commands. Just type like you're talking to a teammate.
How It Works
You type in Teams
→ Azure Bot Service POSTs the activity to your API Gateway
→ Events Lambda (fast, <3s):
• Validates request
• Resolves agent space from routing config (channel > team > default)
• Creates/reuses chat session (per-thread in channels)
• Posts "🔍 Looking into it..." immediately
• Invokes Worker Lambda asynchronously
• Returns 200 to Teams
→ Worker Lambda (up to 15 min):
• Calls DevOps Agent send_message (streaming)
• Parses response for investigation task IDs
• Stores task-to-thread mapping in S3
• Posts full response to Teams
→ If investigation kicks off (minutes later):
• EventBridge fires "Investigation Completed"
• Notify Lambda fetches summary from journal records
• Posts results to the same Teams thread
Prerequisites
- An AWS account with an AWS DevOps Agent space already set up and working
- A Microsoft 365 subscription (Basic or higher) — needed for custom app sideloading.
- An Azure subscription (free tier is fine) for the Bot registration
- AWS CLI installed and configured
Step 1: Create the Azure App Registration
- Sign into https://portal.azure.com with your Microsoft 365 admin account
- Go to Azure Active Directory → App registrations → New registration
- Fill in:
- Name:
DevOps Agent Bot - Supported account types: "Accounts in any organizational directory (Multi-tenant)"
- Redirect URI: leave blank
- Name:
- Click Register
- Copy the Application (client) ID — this is your
appId
Create a Client Secret
- In the app registration → Certificates & secrets → New client secret
- Description:
bot-secret, Expiry: 24 months - Click Add
- Copy the Value immediately — it disappears after you leave the page. This is your
appPassword
Step 2: Create the Azure Bot Resource
- In Azure Portal → search "Azure Bot" → Create
- Settings:
- Bot handle:
devops-agent-teams - Pricing: Free (F0)
- Microsoft App ID: select "Use existing app registration" → paste your
appId&tenantId - App type: Multi Tenant
- Bot handle:
- Click Create
- Once created → Configuration → set Messaging endpoint to:
(You'll get this URL after deploying the CloudFormation stack in Step 3)https://<your-api-gateway-id>.execute-api.us-east-1.amazonaws.com/teams-events - Channels → click Microsoft Teams → accept terms → Save
Step 3: Deploy the Infrastructure
Here's the CloudFormation template. It creates the API Gateway, Lambda function, S3 bucket for state, Secrets Manager secret, and IAM roles.
AWSTemplateFormatVersion: '2010-09-09' Description: > DevOps Agent Teams Integration (Chat-Only) - Chat with DevOps Agent directly in Microsoft Teams. Investigation results posted as thread replies. Parameters: AgentSpaceId: Type: String Description: DevOps Agent Space ID (UUID) MicrosoftAppId: Type: String Description: Azure App Registration (client) ID MicrosoftAppPassword: Type: String Description: Azure App Registration client secret NoEcho: true MicrosoftTenantId: Type: String Description: Microsoft 365 tenant ID Resources: TeamsAppSecret: Type: AWS::SecretsManager::Secret Properties: Name: devops-agent-teams-app-credentials Description: Microsoft Teams Bot app credentials SecretString: !Sub | {"appId":"${MicrosoftAppId}","appPassword":"${MicrosoftAppPassword}","tenantId":"${MicrosoftTenantId}"} StateBucket: Type: AWS::S3::Bucket Properties: BucketName: !Sub 'devops-agent-teams-state-${AWS::AccountId}' PublicAccessBlockConfiguration: BlockPublicAcls: true BlockPublicPolicy: true IgnorePublicAcls: true RestrictPublicBuckets: true LifecycleConfiguration: Rules: - Id: expire-thread-maps Status: Enabled Prefix: thread-map-by-task/ ExpirationInDays: 1 - Id: expire-chat-sessions Status: Enabled Prefix: chat-sessions/ ExpirationInDays: 7 - Id: expire-conversation-refs Status: Enabled Prefix: conversation-refs/ ExpirationInDays: 30 HttpApi: Type: AWS::ApiGatewayV2::Api Properties: Name: devops-agent-teams-api ProtocolType: HTTP HttpApiStage: Type: AWS::ApiGatewayV2::Stage Properties: ApiId: !Ref HttpApi StageName: $default AutoDeploy: true EventsIntegration: Type: AWS::ApiGatewayV2::Integration Properties: ApiId: !Ref HttpApi IntegrationType: AWS_PROXY IntegrationUri: !GetAtt EventsLambda.Arn PayloadFormatVersion: '2.0' EventsRoute: Type: AWS::ApiGatewayV2::Route Properties: ApiId: !Ref HttpApi RouteKey: POST /teams-events Target: !Sub 'integrations/${EventsIntegration}' EventsLambdaRole: Type: AWS::IAM::Role Properties: RoleName: devops-agent-teams-events-role AssumeRolePolicyDocument: Version: '2012-10-17' Statement: - Effect: Allow Principal: Service: lambda.amazonaws.com Action: sts:AssumeRole ManagedPolicyArns: - arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole Policies: - PolicyName: SecretsAccess PolicyDocument: Version: '2012-10-17' Statement: - Effect: Allow Action: secretsmanager:GetSecretValue Resource: !Ref TeamsAppSecret - PolicyName: S3Access PolicyDocument: Version: '2012-10-17' Statement: - Effect: Allow Action: - s3:GetObject - s3:PutObject - s3:ListBucket Resource: - !Sub '${StateBucket.Arn}' - !Sub '${StateBucket.Arn}/*' - PolicyName: DevOpsAgentAccess PolicyDocument: Version: '2012-10-17' Statement: - Effect: Allow Action: - aidevops:CreateChat - aidevops:SendMessage Resource: !Sub 'arn:aws:aidevops:${AWS::Region}:${AWS::AccountId}:agentspace/*' EventsLambda: Type: AWS::Lambda::Function Properties: FunctionName: devops-agent-teams-events Runtime: python3.12 Handler: index.handler Role: !GetAtt EventsLambdaRole.Arn Timeout: 90 MemorySize: 256 Environment: Variables: AGENT_SPACE_ID: !Ref AgentSpaceId STATE_BUCKET: !Sub 'devops-agent-teams-state-${AWS::AccountId}' APP_SECRET_ID: devops-agent-teams-app-credentials Code: ZipFile: | import json def handler(event, context): return {'statusCode': 200, 'body': json.dumps({'status': 'ok'})} EventsLambdaPermission: Type: AWS::Lambda::Permission Properties: FunctionName: !Ref EventsLambda Action: lambda:InvokeFunction Principal: apigateway.amazonaws.com SourceArn: !Sub 'arn:aws:execute-api:${AWS::Region}:${AWS::AccountId}:${HttpApi}/*/*' Outputs: TeamsEventsUrl: Description: Set this as your Azure Bot's Messaging Endpoint Value: !Sub 'https://${HttpApi}.execute-api.${AWS::Region}.amazonaws.com/teams-events' StateBucketName: Description: S3 bucket for thread maps and chat sessions Value: !Ref StateBucket SetupNextSteps: Description: Complete setup by configuring your Azure Bot Value: !Sub | 1. Go to Azure Portal > Your Bot > Configuration 2. Set Messaging endpoint to: https://${HttpApi}.execute-api.${AWS::Region}.amazonaws.com/teams-events 3. Add the bot to your Teams channel
Deploy it:
aws cloudformation deploy \ --template-file cloudformation.yaml \ --stack-name devops-agent-teams \ --capabilities CAPABILITY_NAMED_IAM \ --parameter-overrides \ AgentSpaceId=your-agent-space-id \ MicrosoftAppId=your-app-id \ MicrosoftAppPassword=your-client-secret \ MicrosoftTenantId=your-tenant-id
After deployment, grab the messaging endpoint from the outputs:
aws cloudformation describe-stacks \ --stack-name devops-agent-teams \ --query 'Stacks[0].Outputs[?OutputKey==`TeamsEventsUrl`].OutputValue' \ --output text
Go back to Azure Portal → Bot resource → Configuration → paste this URL as the Messaging endpoint.
Step 4: Deploy the Lambda Code
The CloudFormation deploys placeholder stubs. You need to deploy the actual Lambda code for all three functions. There are three source files — create each one, zip it, and push it.
4a. Events Lambda (lambda-events/index.py)
This is the entry point — validates requests, resolves routing, handles admin commands, and invokes the worker async.
Create lambda-events/index.py:
""" Lambda: devops-agent-teams-events Handles Microsoft Teams Bot Framework messages: - Validates incoming JWT tokens from Azure Bot Service - Routes messages to the correct agent space based on channel/team mapping - Processes new messages and thread replies - Forwards to DevOps Agent chat via async Worker Lambda Flow: 1. Teams user sends message to bot 2. Azure Bot Service POSTs activity to this Lambda via API Gateway 3. Lambda authenticates the request (validates JWT) 4. Resolves agent_space_id from routing config (channel > team > default) 5. Creates or reuses DevOps Agent chat session 6. Posts "thinking" ack immediately 7. Invokes Worker Lambda async with resolved agent_space_id """ import json import time import os import re import boto3 import urllib.request import urllib.error import urllib.parse DEFAULT_AGENT_SPACE_ID = os.environ['AGENT_SPACE_ID'] STATE_BUCKET = os.environ['STATE_BUCKET'] APP_SECRET_ID = os.environ['APP_SECRET_ID'] WORKER_FUNCTION = os.environ.get('WORKER_FUNCTION', 'devops-agent-teams-worker') REGION = os.environ.get('AWS_REGION', 'us-east-1') secrets_client = boto3.client('secretsmanager', region_name=REGION) s3_client = boto3.client('s3', region_name=REGION) devops_client = boto3.client('devops-agent', region_name=REGION) lambda_client = boto3.client('lambda', region_name=REGION) _app_config = None _bot_token = None _bot_token_expiry = 0 _routing_config = None _routing_config_expiry = 0 # Cache routing config for 60 seconds to avoid S3 read on every message ROUTING_CACHE_TTL = 60 def get_app_config(): """Load Azure app credentials from Secrets Manager.""" global _app_config if _app_config is None: resp = secrets_client.get_secret_value(SecretId=APP_SECRET_ID) _app_config = json.loads(resp['SecretString']) return _app_config def get_routing_config(): """Load space routing config from S3 with caching.""" global _routing_config, _routing_config_expiry now = time.time() if _routing_config and now < _routing_config_expiry: return _routing_config try: resp = s3_client.get_object( Bucket=STATE_BUCKET, Key='config/space-routing.json' ) _routing_config = json.loads(resp['Body'].read()) _routing_config_expiry = now + ROUTING_CACHE_TTL print(f"Loaded routing config: {len(_routing_config.get('channels', {}))} channels, " f"{len(_routing_config.get('teams', {}))} teams") return _routing_config except Exception as e: print(f"No routing config found ({e}) - using default space") # Return a minimal config with just the default _routing_config = {"default": DEFAULT_AGENT_SPACE_ID} _routing_config_expiry = now + ROUTING_CACHE_TTL return _routing_config def resolve_agent_space(activity): """ Resolve which agent space to route this message to. Priority: channel > team > default Returns (agent_space_id, is_explicit_match) """ routing = get_routing_config() channel_data = activity.get('channelData', {}) channel_id = channel_data.get('channel', {}).get('id', '') team_id = channel_data.get('team', {}).get('id', '') # 1. Check channel-level mapping (most specific) if channel_id and channel_id in routing.get('channels', {}): space_id = routing['channels'][channel_id] print(f"Routed by channel {channel_id} -> {space_id}") return space_id, True # 2. Check team-level mapping if team_id and team_id in routing.get('teams', {}): space_id = routing['teams'][team_id] print(f"Routed by team {team_id} -> {space_id}") return space_id, True # 3. Fall back to default space_id = routing.get('default', DEFAULT_AGENT_SPACE_ID) print(f"Routed to default -> {space_id}") return space_id, False def get_bot_token(): """Get a Bot Framework OAuth token for replying to conversations.""" global _bot_token, _bot_token_expiry if _bot_token and time.time() < _bot_token_expiry - 60: return _bot_token config = get_app_config() app_id = config['appId'] app_password = config['appPassword'] tenant_id = config['tenantId'] # Use tenant-specific endpoint for Teams replies token_url = f'https://login.microsoftonline.com/{tenant_id}/oauth2/v2.0/token' data = urllib.parse.urlencode({ 'grant_type': 'client_credentials', 'client_id': app_id, 'client_secret': app_password, 'scope': 'https://api.botframework.com/.default' }).encode() req = urllib.request.Request(token_url, data=data, headers={ 'Content-Type': 'application/x-www-form-urlencoded' }) with urllib.request.urlopen(req, timeout=10) as resp: token_data = json.loads(resp.read()) _bot_token = token_data['access_token'] _bot_token_expiry = time.time() + token_data.get('expires_in', 3600) print(f"Bot token acquired") return _bot_token def validate_auth_header(event): """ Validate the incoming JWT token from Azure Bot Service. Verifies signature, issuer, audience, and expiry using Microsoft's JWKS. """ headers = event.get('headers', {}) auth_header = headers.get('authorization', '') if not auth_header.startswith('Bearer '): print("WARNING: No valid Authorization header") return False token = auth_header[7:] # Strip "Bearer " try: import jwt from jwt import PyJWKClient # Microsoft Bot Framework OpenID config # Keys endpoint for Bot Framework tokens jwks_url = "https://login.botframework.com/v1/.well-known/keys" # Get signing keys (PyJWKClient caches internally) jwks_client = PyJWKClient(jwks_url, cache_keys=True, lifespan=3600) signing_key = jwks_client.get_signing_key_from_jwt(token) # Decode and verify config = get_app_config() decoded = jwt.decode( token, signing_key.key, algorithms=["RS256"], audience=config['appId'], issuer="https://api.botframework.com", options={ "verify_exp": True, "verify_aud": True, "verify_iss": True, } ) print(f"JWT valid: sub={decoded.get('sub', 'n/a')}, iss={decoded.get('iss', 'n/a')}") return True except Exception as e: print(f"JWT validation failed: {e}") return False def get_or_create_chat_session(session_key, agent_space_id): """Get existing chat session or create a new one for this Teams conversation.""" key = f"chat-sessions/{session_key}" try: resp = s3_client.get_object(Bucket=STATE_BUCKET, Key=key) data = json.loads(resp['Body'].read()) if data.get('chat_execution_id'): # Verify the session is for the same agent space stored_space = data.get('agent_space_id', DEFAULT_AGENT_SPACE_ID) if stored_space != agent_space_id: print(f"Session space mismatch: stored={stored_space}, requested={agent_space_id}. Creating new session.") raise KeyError("space mismatch") return data['chat_execution_id'] # Have task context but no chat - create with context task_id = data.get('task_id', '') title = data.get('title', '') summary = data.get('summary', '') chat_resp = devops_client.create_chat(agentSpaceId=agent_space_id) execution_id = chat_resp['executionId'] # Send context message context_message = f"""You are continuing a conversation about a DevOps investigation. Task ID: {task_id} Title: {title} Agent Space: {agent_space_id} Investigation Summary: {summary} The user will now ask follow-up questions about this specific investigation.""" try: context_resp = devops_client.send_message( agentSpaceId=agent_space_id, executionId=execution_id, content=context_message ) for _ in context_resp.get('events', []): pass except Exception as e: print(f"Warning: context message failed: {e}") data['chat_execution_id'] = execution_id data['agent_space_id'] = agent_space_id s3_client.put_object( Bucket=STATE_BUCKET, Key=key, Body=json.dumps(data).encode(), ContentType='application/json' ) print(f"Created chat session {execution_id} with context for task {task_id}") return execution_id except Exception as e: if 'NoSuchKey' in str(e) or '404' in str(e) or '403' in str(e) or 'space mismatch' in str(e): # No existing session (or space changed) - create fresh chat_resp = devops_client.create_chat(agentSpaceId=agent_space_id) execution_id = chat_resp['executionId'] s3_client.put_object( Bucket=STATE_BUCKET, Key=key, Body=json.dumps({ 'chat_execution_id': execution_id, 'agent_space_id': agent_space_id, 'created_at': time.time() }).encode(), ContentType='application/json' ) print(f"Created fresh chat session {execution_id} for space {agent_space_id}") return execution_id raise def split_message(text, max_chars=25000): """Split a long message into chunks at paragraph boundaries.""" if len(text) <= max_chars: return [text] chunks = [] while text: if len(text) <= max_chars: chunks.append(text) break split_at = text.rfind('\n\n', 0, max_chars) if split_at == -1: split_at = text.rfind('\n', 0, max_chars) if split_at == -1: split_at = text.rfind(' ', 0, max_chars) if split_at == -1: split_at = max_chars chunks.append(text[:split_at]) text = text[split_at:].lstrip('\n') return chunks def reply_to_teams(service_url, conversation_id, activity_id, text, reply_to_id=None): """Send a reply back to the Teams conversation. Splits long messages.""" chunks = split_message(text) for i, chunk in enumerate(chunks): if len(chunks) > 1: chunk = f"({i+1}/{len(chunks)})\n\n{chunk}" if i > 0 else chunk _send_single_reply(service_url, conversation_id, activity_id, chunk, reply_to_id) if i < len(chunks) - 1: time.sleep(0.5) def _send_single_reply(service_url, conversation_id, activity_id, text, reply_to_id=None): """Send a single reply activity to Teams.""" token = get_bot_token() config = get_app_config() reply_activity = { 'type': 'message', 'text': text, 'textFormat': 'markdown', 'from': { 'id': config['appId'], 'name': 'DevOps Agent' }, 'conversation': { 'id': conversation_id, 'tenantId': config['tenantId'] } } if reply_to_id: reply_activity['replyToId'] = reply_to_id url = f"{service_url.rstrip('/')}/v3/conversations/{conversation_id}/activities" data = json.dumps(reply_activity).encode() req = urllib.request.Request(url, data=data, headers={ 'Content-Type': 'application/json', 'Authorization': f'Bearer {token}' }) try: with urllib.request.urlopen(req, timeout=30) as resp: result = json.loads(resp.read()) print(f"Teams reply sent: {result.get('id', 'unknown')}") return result except urllib.error.URLError as e: print(f"Error sending Teams reply: {e}") raise def store_conversation_reference(conversation_id, activity): """Store conversation reference for proactive messaging (notify Lambda).""" ref = { 'serviceUrl': activity.get('serviceUrl', ''), 'conversation': activity.get('conversation', {}), 'channelId': activity.get('channelId', ''), 'from': activity.get('from', {}), 'recipient': activity.get('recipient', {}), 'stored_at': time.time() } s3_client.put_object( Bucket=STATE_BUCKET, Key=f'conversation-refs/{conversation_id}', Body=json.dumps(ref).encode(), ContentType='application/json' ) def update_routing_config(routing): """Write updated routing config to S3 and invalidate cache.""" global _routing_config, _routing_config_expiry s3_client.put_object( Bucket=STATE_BUCKET, Key='config/space-routing.json', Body=json.dumps(routing, indent=2).encode(), ContentType='application/json' ) # Invalidate cache so next request sees the change _routing_config = routing _routing_config_expiry = time.time() + ROUTING_CACHE_TTL print(f"Routing config updated: {len(routing.get('channels', {}))} channels, " f"{len(routing.get('teams', {}))} teams") def handle_admin_command(text, activity, service_url, conversation_id, activity_id): """ Handle slash commands. Returns a response dict if handled, None otherwise. Commands: /register, /unregister, /status """ text_lower = text.lower().strip() if not text_lower.startswith('/'): return None channel_data = activity.get('channelData', {}) channel_id = channel_data.get('channel', {}).get('id', '') team_id = channel_data.get('team', {}).get('id', '') team_name = channel_data.get('team', {}).get('name', 'Unknown Team') channel_name = channel_data.get('channel', {}).get('name', 'Unknown Channel') uuid_pattern = r'[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}' # === /register <space-uuid> [team] [confirm] === if text_lower.startswith('/register'): match = re.search(uuid_pattern, text) if not match: space_list = "" try: spaces_resp = devops_client.list_agent_spaces() spaces = spaces_resp.get('agentSpaces', []) if spaces: space_list = "\n\n**Available spaces:**\n" for space in spaces: space_list += f"- `{space.get('agentSpaceId', '')}` \u2014 {space.get('name', 'Unnamed')}\n" except Exception: pass reply_to_teams(service_url, conversation_id, activity_id, f"\u26a0\ufe0f **Usage:** `/register <agent-space-uuid>` or `/register <uuid> team`{space_list}") return {'statusCode': 200, 'body': json.dumps({'status': 'ok'})} space_id = match.group(0) remaining_text = text_lower.split(space_id)[-1] if space_id in text else '' is_team_level = 'team' in remaining_text is_confirmed = 'confirm' in remaining_text routing = get_routing_config() # Check if space is already assigned elsewhere assigned_channels = {sp: ch for ch, sp in routing.get('channels', {}).items()} assigned_teams = {sp: tm for tm, sp in routing.get('teams', {}).items()} if not is_confirmed and space_id in assigned_channels: if assigned_channels[space_id] != channel_id: reply_to_teams(service_url, conversation_id, activity_id, f"\u26a0\ufe0f **Space already assigned** to another channel.\n\nReply `/register {space_id} confirm` to share.") return {'statusCode': 200, 'body': json.dumps({'status': 'ok'})} if not is_confirmed and space_id in assigned_teams: if assigned_teams[space_id] != team_id: reply_to_teams(service_url, conversation_id, activity_id, f"\u26a0\ufe0f **Space already assigned** to another team.\n\nReply `/register {space_id}{' team' if is_team_level else ''} confirm` to share.") return {'statusCode': 200, 'body': json.dumps({'status': 'ok'})} if is_team_level: if not team_id: reply_to_teams(service_url, conversation_id, activity_id, "\u26a0\ufe0f Can't register at team level \u2014 no team context (are you in a DM?)") return {'statusCode': 200, 'body': json.dumps({'status': 'ok'})} current = routing.get('teams', {}).get(team_id) if current and current != space_id and not is_confirmed: reply_to_teams(service_url, conversation_id, activity_id, f"\u26a0\ufe0f **Already registered** to `{current}`.\n\nReply `/register {space_id} team confirm` to overwrite.") return {'statusCode': 200, 'body': json.dumps({'status': 'ok'})} if 'teams' not in routing: routing['teams'] = {} routing['teams'][team_id] = space_id update_routing_config(routing) reply_to_teams(service_url, conversation_id, activity_id, f"\u2705 **Team registered!** {team_name} \u2192 `{space_id}`") else: if not channel_id: routing['default'] = space_id update_routing_config(routing) reply_to_teams(service_url, conversation_id, activity_id, f"\u2705 **Default space updated:** `{space_id}`") return {'statusCode': 200, 'body': json.dumps({'status': 'ok'})} if 'channels' not in routing: routing['channels'] = {} current = routing.get('channels', {}).get(channel_id) if current and current != space_id and not is_confirmed: reply_to_teams(service_url, conversation_id, activity_id, f"\u26a0\ufe0f **Already registered** to `{current}`.\n\nReply `/register {space_id} confirm` to overwrite.") return {'statusCode': 200, 'body': json.dumps({'status': 'ok'})} routing['channels'][channel_id] = space_id update_routing_config(routing) reply_to_teams(service_url, conversation_id, activity_id, f"\u2705 **Channel registered!** {channel_name} \u2192 `{space_id}`") return {'statusCode': 200, 'body': json.dumps({'status': 'ok'})} # === /unregister [team] === elif text_lower.startswith('/unregister'): is_team_level = 'team' in text_lower routing = get_routing_config() if is_team_level: if team_id and team_id in routing.get('teams', {}): del routing['teams'][team_id] update_routing_config(routing) reply_to_teams(service_url, conversation_id, activity_id, f"\u2705 **Team unregistered.** Will use default space.") else: reply_to_teams(service_url, conversation_id, activity_id, f"\u2139\ufe0f This team isn't registered.") else: if channel_id and channel_id in routing.get('channels', {}): del routing['channels'][channel_id] update_routing_config(routing) reply_to_teams(service_url, conversation_id, activity_id, f"\u2705 **Channel unregistered.** Will fall back to team or default.") else: reply_to_teams(service_url, conversation_id, activity_id, f"\u2139\ufe0f This channel isn't registered.") return {'statusCode': 200, 'body': json.dumps({'status': 'ok'})} # === /status === elif text_lower == '/status': routing = get_routing_config() current_space = None route_type = None if channel_id and channel_id in routing.get('channels', {}): current_space = routing['channels'][channel_id] route_type = "channel" elif team_id and team_id in routing.get('teams', {}): current_space = routing['teams'][team_id] route_type = "team" else: current_space = routing.get('default', DEFAULT_AGENT_SPACE_ID) route_type = "default" space_name = "" try: spaces_resp = devops_client.list_agent_spaces() for space in spaces_resp.get('agentSpaces', []): if space.get('agentSpaceId') == current_space: space_name = f" \u2014 {space.get('name', '')}" break except Exception: pass emoji = "\U0001f7e2" if route_type != "default" else "\u26aa" msg = f"**{emoji} Status**\n\n- **Space:** `{current_space}`{space_name}\n- **Route:** {route_type}" if route_type == "default": msg += f"\n\n_Not registered. Use `/register <space-uuid>` to assign._" reply_to_teams(service_url, conversation_id, activity_id, msg) return {'statusCode': 200, 'body': json.dumps({'status': 'ok'})} # Not a recognized command return None def handler(event, context): """Handle incoming Bot Framework activities from Teams.""" print(f"Received request: method={event.get('requestContext', {}).get('http', {}).get('method')}") # Validate auth if not validate_auth_header(event): return {'statusCode': 401, 'body': json.dumps({'error': 'Unauthorized'})} # Parse activity raw_body = event.get('body', '{}') activity = json.loads(raw_body) if isinstance(raw_body, str) else raw_body activity_type = activity.get('type', '') print(f"Activity: type={activity_type}, from={activity.get('from', {}).get('name', 'unknown')}") # Log channel data for routing config discovery channel_data = activity.get('channelData', {}) if channel_data: print(f"ChannelData: team={channel_data.get('team', {}).get('id', 'n/a')}, " f"channel={channel_data.get('channel', {}).get('id', 'n/a')}") # Handle different activity types if activity_type == 'conversationUpdate': members_added = activity.get('membersAdded', []) config = get_app_config() for member in members_added: if member.get('id') == config['appId']: service_url = activity.get('serviceUrl', '') conversation_id = activity.get('conversation', {}).get('id', '') if service_url and conversation_id: store_conversation_reference(conversation_id, activity) reply_to_teams( service_url, conversation_id, None, "👋 **DevOps Agent** is ready! Send me a message to start a conversation " "or ask me to investigate an issue.\n\n" "**Tips:**\n" "- Include ARNs, account IDs, regions for better investigations\n" "- I'll respond in this thread with findings\n" "- Follow up with questions - I maintain context" ) return {'statusCode': 200, 'body': json.dumps({'status': 'ok'})} elif activity_type == 'message': text = activity.get('text', '').strip() service_url = activity.get('serviceUrl', '') conversation_id = activity.get('conversation', {}).get('id', '') conversation_type = activity.get('conversation', {}).get('conversationType', 'personal') activity_id = activity.get('id', '') reply_to_id = activity.get('replyToId') print(f"Message: serviceUrl={service_url}, conversation={conversation_id}, " f"type={conversation_type}, text={text[:50]}") if not text or not conversation_id: return {'statusCode': 200, 'body': json.dumps({'status': 'ok'})} # Remove bot mention if present (Teams adds <at>BotName</at> prefix) text = re.sub(r'<at>.*?</at>\s*', '', text).strip() # Strip leading colon/punctuation left after mention removal (e.g. "@Bot: /command") text = re.sub(r'^[:\-,;]\s*', '', text).strip() if not text: return {'statusCode': 200, 'body': json.dumps({'status': 'ok'})} # Store conversation reference for proactive messaging store_conversation_reference(conversation_id, activity) # === ADMIN COMMANDS === admin_result = handle_admin_command(text, activity, service_url, conversation_id, activity_id) if admin_result: return admin_result # === ROUTING: Resolve which agent space handles this message === agent_space_id, is_explicit_route = resolve_agent_space(activity) # If channel/team is not registered, prompt the user to register if not is_explicit_route and conversation_type != 'personal': # Dynamically fetch available spaces from the DevOps Agent API routing = get_routing_config() # Collect already-assigned space IDs assigned_spaces = set(routing.get('channels', {}).values()) assigned_spaces.update(routing.get('teams', {}).values()) space_list = "" try: spaces_resp = devops_client.list_agent_spaces() spaces = spaces_resp.get('agentSpaces', []) if spaces: space_list = "\n\n**Available spaces:**\n" for space in spaces: space_id = space.get('agentSpaceId', '') name = space.get('name', 'Unnamed') if space_id in assigned_spaces: space_list += f"- ~~`{space_id}`~~ — {name} *(already assigned)*\n" else: space_list += f"- `{space_id}` — {name}\n" except Exception as e: print(f"Error listing spaces: {e}") # Fallback to routing config if API fails spaces_map = routing.get('spaces', {}) if spaces_map: space_list = "\n\n**Known spaces:**\n" for sp_id, label in spaces_map.items(): if sp_id in assigned_spaces: space_list += f"- ~~`{sp_id}`~~ — {label} *(already assigned)*\n" else: space_list += f"- `{sp_id}` — {label}\n" reply_to_teams(service_url, conversation_id, activity_id, f"⚠️ **This channel is not registered to an agent space.**\n\n" f"To start using DevOps Agent here, register a space:\n\n" f"```\n/register <agent-space-uuid>\n```\n" f"{space_list}\n" f"Type `/spaces` to see all available spaces, or `/help` for all commands.", reply_to_id=activity_id) return {'statusCode': 200, 'body': json.dumps({'status': 'ok'})} # Determine session key based on conversation type if conversation_type == 'personal': session_key = conversation_id else: if ';messageid=' in conversation_id: session_key = conversation_id.split(';messageid=')[1] else: session_key = activity_id print(f"Session key: {session_key} (type={conversation_type}, space={agent_space_id})") try: # Get or create chat session for this thread/conversation execution_id = get_or_create_chat_session(session_key, agent_space_id) print(f"Using chat session: {execution_id} for space: {agent_space_id}") # Post "thinking" message immediately try: reply_to_teams(service_url, conversation_id, activity_id, "🔍 Looking into it...", reply_to_id=activity_id) except Exception as ack_err: print(f"Ack message failed: {ack_err}") # Invoke worker Lambda asynchronously WITH the resolved space ID worker_payload = { 'agent_space_id': agent_space_id, 'service_url': service_url, 'conversation_id': conversation_id, 'execution_id': execution_id, 'message': text, 'delay_seconds': 0 } lambda_client.invoke( FunctionName=WORKER_FUNCTION, InvocationType='Event', Payload=json.dumps(worker_payload).encode() ) print(f"Worker invoked async for space: {agent_space_id}, session: {execution_id}") except Exception as e: print(f"Error handling message: {e}") error_activity = { 'type': 'message', 'text': f"⚠️ Error: {str(e)}", 'textFormat': 'markdown' } return { 'statusCode': 200, 'headers': {'Content-Type': 'application/json'}, 'body': json.dumps(error_activity) } return {'statusCode': 200, 'body': json.dumps({'status': 'ok'})} # Other activity types - acknowledge return {'statusCode': 200, 'body': json.dumps({'status': 'ok'})}
4b. Worker Lambda (lambda-worker/index.py)
The heavy lifter — calls DevOps Agent (streaming), collects the response with deduplication, maps investigation task IDs, and posts back to Teams.
Create lambda-worker/index.py:
""" Lambda: devops-agent-teams-worker Async worker that handles the slow part: - Calls DevOps Agent send_message (streaming, may take minutes) - Posts the response back to Teams conversation - Stores task-to-thread mapping with agent_space_id for notify Lambda Invoked asynchronously by the events Lambda. Receives agent_space_id in the event payload (multi-space routing). """ import json import time import os import re import boto3 import urllib.request import urllib.error import urllib.parse DEFAULT_AGENT_SPACE_ID = os.environ.get('AGENT_SPACE_ID', '') STATE_BUCKET = os.environ['STATE_BUCKET'] APP_SECRET_ID = os.environ['APP_SECRET_ID'] REGION = os.environ.get('AWS_REGION', 'us-east-1') secrets_client = boto3.client('secretsmanager', region_name=REGION) s3_client = boto3.client('s3', region_name=REGION) devops_client = boto3.client('devops-agent', region_name=REGION) _app_config = None _bot_token = None _bot_token_expiry = 0 def get_app_config(): global _app_config if _app_config is None: resp = secrets_client.get_secret_value(SecretId=APP_SECRET_ID) _app_config = json.loads(resp['SecretString']) return _app_config def get_bot_token(): global _bot_token, _bot_token_expiry if _bot_token and time.time() < _bot_token_expiry - 60: return _bot_token config = get_app_config() app_id = config['appId'] app_password = config['appPassword'] tenant_id = config['tenantId'] token_url = f'https://login.microsoftonline.com/{tenant_id}/oauth2/v2.0/token' data = urllib.parse.urlencode({ 'grant_type': 'client_credentials', 'client_id': app_id, 'client_secret': app_password, 'scope': 'https://api.botframework.com/.default' }).encode() req = urllib.request.Request(token_url, data=data, headers={ 'Content-Type': 'application/x-www-form-urlencoded' }) with urllib.request.urlopen(req, timeout=10) as resp: token_data = json.loads(resp.read()) _bot_token = token_data['access_token'] _bot_token_expiry = time.time() + token_data.get('expires_in', 3600) return _bot_token def split_message(text, max_chars=25000): """Split a long message into chunks at paragraph boundaries.""" if len(text) <= max_chars: return [text] chunks = [] while text: if len(text) <= max_chars: chunks.append(text) break split_at = text.rfind('\n\n', 0, max_chars) if split_at == -1: split_at = text.rfind('\n', 0, max_chars) if split_at == -1: split_at = text.rfind(' ', 0, max_chars) if split_at == -1: split_at = max_chars chunks.append(text[:split_at]) text = text[split_at:].lstrip('\n') return chunks def post_to_teams(service_url, conversation_id, text): """Post a message to the Teams conversation.""" token = get_bot_token() config = get_app_config() chunks = split_message(text) for i, chunk in enumerate(chunks): if len(chunks) > 1 and i > 0: chunk = f"({i+1}/{len(chunks)})\n\n{chunk}" reply_activity = { 'type': 'message', 'text': chunk, 'textFormat': 'markdown', 'from': { 'id': config['appId'], 'name': 'DevOps Agent' }, 'conversation': { 'id': conversation_id, 'tenantId': config['tenantId'] } } url = f"{service_url.rstrip('/')}/v3/conversations/{conversation_id}/activities" data = json.dumps(reply_activity).encode() req = urllib.request.Request(url, data=data, headers={ 'Content-Type': 'application/json', 'Authorization': f'Bearer {token}' }) try: with urllib.request.urlopen(req, timeout=30) as resp: result = json.loads(resp.read()) print(f"Teams reply sent: {result.get('id', 'unknown')}") except urllib.error.URLError as e: print(f"Error sending Teams reply: {e}") raise if i < len(chunks) - 1: time.sleep(0.5) def send_message_to_agent(agent_space_id, execution_id, message): """Send a message to DevOps Agent and collect the full response.""" resp = devops_client.send_message( agentSpaceId=agent_space_id, executionId=execution_id, content=message ) full_text = [] events = resp.get('events', []) for event in events: if 'contentBlockDelta' in event: delta = event['contentBlockDelta'].get('delta', {}) if isinstance(delta, dict): text_delta = delta.get('textDelta', {}) if isinstance(text_delta, dict): text = text_delta.get('text', '') if text: full_text.append(text) elif 'contentBlockStop' in event: text = event['contentBlockStop'].get('text', '') if text: # Only append if this text isn't already in what we've collected collected_so_far = ''.join(full_text) if text not in collected_so_far: full_text.append(text) elif 'summary' in event: content = event['summary'].get('content', '') if content: collected_so_far = ''.join(full_text) if content not in collected_so_far: full_text.append(content) elif 'responseFailed' in event: error_msg = event['responseFailed'].get('errorMessage', 'Unknown error') return f"⚠️ DevOps Agent error: {error_msg}" return ''.join(full_text) if full_text else "No response from DevOps Agent." def handler(event, context): """Async worker — calls agent with the routed space ID, then posts reply.""" print(f"Worker invoked: {json.dumps(event)[:200]}") # Read agent_space_id from event payload (set by events Lambda routing) agent_space_id = event.get('agent_space_id', DEFAULT_AGENT_SPACE_ID) service_url = event['service_url'] conversation_id = event['conversation_id'] execution_id = event['execution_id'] message = event['message'] delay_seconds = event.get('delay_seconds', 30) print(f"Worker: space={agent_space_id}, session={execution_id}") # Wait before calling agent (let it start processing) if delay_seconds > 0: print(f"Waiting {delay_seconds}s before calling agent...") time.sleep(delay_seconds) try: # Call DevOps Agent with the routed space ID print(f"Sending message to agent (space: {agent_space_id}, session: {execution_id})") response_text = send_message_to_agent(agent_space_id, execution_id, message) print(f"Agent response length: {len(response_text)}") # Parse for NEW investigation task IDs and store thread mapping. # Only map investigations that were just started (have a recent timestamp), # not historical ones the agent mentions in its response. import datetime task_pattern = r'\[\[investigation:([0-9a-f-]{36}):Investigation (\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2})' now = datetime.datetime.utcnow() for match in re.finditer(task_pattern, response_text): task_id = match.group(1) task_time_str = match.group(2) try: task_time = datetime.datetime.fromisoformat(task_time_str) age_seconds = (now - task_time).total_seconds() # Only map if the investigation was created within the last 5 minutes if age_seconds > 300: print(f"Skipping old investigation reference: {task_id} (age: {age_seconds:.0f}s)") continue except (ValueError, TypeError): pass # If we can't parse the time, map it anyway try: s3_client.put_object( Bucket=STATE_BUCKET, Key=f'thread-map-by-task/{task_id}', Body=json.dumps({ 'serviceUrl': service_url, 'conversation': {'id': conversation_id}, 'agent_space_id': agent_space_id }).encode(), ContentType='application/json' ) print(f"Mapped NEW task {task_id} -> conversation (space: {agent_space_id})") except Exception as map_err: print(f"Error storing task mapping: {map_err}") # Post response to Teams post_to_teams(service_url, conversation_id, response_text) print("Done - response posted to Teams") except Exception as e: print(f"Worker error: {e}") try: post_to_teams(service_url, conversation_id, f"⚠️ Error: {str(e)}") except Exception: print("Failed to post error message to Teams") return {'statusCode': 200}
4c. Notify Lambda (lambda-notify/index.py)
Triggered by EventBridge when an investigation completes. Looks up the originating Teams thread and posts results.
Create lambda-notify/index.py:
""" Lambda: devops-agent-teams-notify Triggered by EventBridge when an investigation completes. Posts the results back to the Teams conversation where it was started. Multi-space aware: reads agent_space_id from the thread-map-by-task S3 object (stored by the worker Lambda) to query the correct agent space for results. """ import json import time import os import boto3 import urllib.request import urllib.error import urllib.parse DEFAULT_AGENT_SPACE_ID = os.environ.get('AGENT_SPACE_ID', '') STATE_BUCKET = os.environ['STATE_BUCKET'] APP_SECRET_ID = os.environ['APP_SECRET_ID'] REGION = os.environ.get('AWS_REGION', 'us-east-1') secrets_client = boto3.client('secretsmanager', region_name=REGION) s3_client = boto3.client('s3', region_name=REGION) devops_client = boto3.client('devops-agent', region_name=REGION) _app_config = None _bot_token = None _bot_token_expiry = 0 def get_app_config(): global _app_config if _app_config is None: resp = secrets_client.get_secret_value(SecretId=APP_SECRET_ID) _app_config = json.loads(resp['SecretString']) return _app_config def get_bot_token(): global _bot_token, _bot_token_expiry if _bot_token and time.time() < _bot_token_expiry - 60: return _bot_token config = get_app_config() tenant_id = config['tenantId'] token_url = f'https://login.microsoftonline.com/{tenant_id}/oauth2/v2.0/token' data = urllib.parse.urlencode({ 'grant_type': 'client_credentials', 'client_id': config['appId'], 'client_secret': config['appPassword'], 'scope': 'https://api.botframework.com/.default' }).encode() req = urllib.request.Request(token_url, data=data, headers={ 'Content-Type': 'application/x-www-form-urlencoded' }) with urllib.request.urlopen(req, timeout=10) as resp: token_data = json.loads(resp.read()) _bot_token = token_data['access_token'] _bot_token_expiry = time.time() + token_data.get('expires_in', 3600) return _bot_token def post_to_teams(service_url, conversation_id, text): """Post a message to Teams conversation.""" token = get_bot_token() config = get_app_config() # Split if needed MAX_CHARS = 25000 chunks = [text] if len(text) <= MAX_CHARS else [] if not chunks: remaining = text while remaining: if len(remaining) <= MAX_CHARS: chunks.append(remaining) break split_at = remaining.rfind('\n\n', 0, MAX_CHARS) if split_at == -1: split_at = remaining.rfind('\n', 0, MAX_CHARS) if split_at == -1: split_at = MAX_CHARS chunks.append(remaining[:split_at]) remaining = remaining[split_at:].lstrip('\n') for chunk in chunks: reply_activity = { 'type': 'message', 'text': chunk, 'textFormat': 'markdown', 'from': {'id': config['appId'], 'name': 'DevOps Agent'}, 'conversation': {'id': conversation_id, 'tenantId': config['tenantId']} } url = f"{service_url.rstrip('/')}/v3/conversations/{conversation_id}/activities" req_data = json.dumps(reply_activity).encode() req = urllib.request.Request(url, data=req_data, headers={ 'Content-Type': 'application/json', 'Authorization': f'Bearer {token}' }) with urllib.request.urlopen(req, timeout=30) as resp: result = json.loads(resp.read()) print(f"Teams reply sent: {result.get('id', 'unknown')}") time.sleep(0.5) def get_investigation_summary(agent_space_id, execution_id): """Get investigation summary from journal records.""" try: resp = devops_client.list_journal_records( agentSpaceId=agent_space_id, executionId=execution_id ) records = resp.get('records', []) # Look for investigation_summary_md first for record in records: if record.get('recordType') == 'investigation_summary_md': return record.get('content', '') # Fallback: look for the final assistant message for record in reversed(records): if record.get('recordType') == 'message': try: content = json.loads(record['content']) if content.get('role') == 'assistant': for block in content.get('content', []): if block.get('type') == 'text' and len(block.get('text', '')) > 200: return block['text'] except (json.JSONDecodeError, TypeError): pass return None except Exception as e: print(f"Error fetching summary: {e}") return None def handler(event, context): """Handle EventBridge investigation completion events.""" print(f"Notify received: {json.dumps(event)[:500]}") event_type = event.get('detail-type', 'Unknown') detail = event.get('detail', {}) metadata = detail.get('metadata', {}) task_id = metadata.get('task_id', '') execution_id = metadata.get('execution_id', '') print(f"Event: {event_type}, task: {task_id}, execution: {execution_id}") # Look up conversation reference AND agent_space_id from the task mapping conversation_ref = None agent_space_id = DEFAULT_AGENT_SPACE_ID try: resp = s3_client.get_object( Bucket=STATE_BUCKET, Key=f'thread-map-by-task/{task_id}' ) conversation_ref = json.loads(resp['Body'].read()) # Read the agent_space_id stored by the worker Lambda agent_space_id = conversation_ref.get('agent_space_id', DEFAULT_AGENT_SPACE_ID) print(f"Found conversation ref for task {task_id} (space: {agent_space_id})") except Exception: print(f"No conversation ref for task {task_id} - checking fallback") # Fallback: try EventBridge event metadata for space ID agent_space_id = metadata.get('agent_space_id', DEFAULT_AGENT_SPACE_ID) # Try to find a conversation ref try: resp = s3_client.list_objects_v2( Bucket=STATE_BUCKET, Prefix='conversation-refs/', MaxKeys=1 ) if resp.get('Contents'): key = resp['Contents'][0]['Key'] ref_resp = s3_client.get_object(Bucket=STATE_BUCKET, Key=key) conversation_ref = json.loads(ref_resp['Body'].read()) print(f"Using fallback conversation ref: {key}") except Exception as e2: print(f"No conversation refs found: {e2}") if not conversation_ref: print("ERROR: No conversation reference - cannot post to Teams") return {'statusCode': 200, 'body': 'no target'} service_url = conversation_ref.get('serviceUrl', '') conversation_id = conversation_ref.get('conversation', {}).get('id', '') if not service_url or not conversation_id: print("ERROR: Missing serviceUrl or conversation_id in reference") return {'statusCode': 200, 'body': 'invalid ref'} # Get task details using the correct agent space title = '' try: task_resp = devops_client.get_backlog_task( agentSpaceId=agent_space_id, taskId=task_id ) task = task_resp.get('task', {}) title = task.get('title', '') except Exception as e: print(f"Error getting task: {e}") # Build the message based on event type emoji_map = { 'Investigation Completed': '✅', 'Investigation Linked': '🔗', 'Investigation Failed': '❌', 'Investigation Timed Out': '⏰', 'Investigation Cancelled': '🚫', 'Investigation Skipped': '⏭️', } emoji = emoji_map.get(event_type, 'ℹ️') # Get summary for completed investigations summary = None if event_type == 'Investigation Completed' and execution_id: summary = get_investigation_summary(agent_space_id, execution_id) # Compose message message = f"**{emoji} {event_type}**\n\n" if title: message += f"**{title}**\n\n" if summary: message += summary elif event_type == 'Investigation Completed': message += "_Investigation completed. Check the DevOps Agent console for full details._" elif event_type == 'Investigation Failed': message += "_Investigation failed. Check the DevOps Agent console for details._" elif event_type == 'Investigation Linked': primary_id = '' try: primary_id = task.get('primaryTaskId', '') except Exception: pass message += f"_Linked to existing investigation: `{primary_id}`_" else: message += f"_Status: {event_type}_" message += f"\n\n---\n_Task: `{task_id}`_" # Post to Teams try: post_to_teams(service_url, conversation_id, message) print(f"Posted {event_type} to Teams for task {task_id}") except Exception as e: print(f"Error posting to Teams: {e}") return {'statusCode': 200, 'task_id': task_id}
4d. Deploy All Three
First, create the Lambda layer for JWT validation (required by the Events Lambda):
STACK_NAME=devops-agent-teams # change if you used a different name REGION=us-east-1 # Build the PyJWT + cryptography layer mkdir -p /tmp/jwt-layer/python pip install PyJWT cryptography -t /tmp/jwt-layer/python --quiet cd /tmp/jwt-layer && zip -qr /tmp/jwt-layer.zip python/ # Publish the layer LAYER_ARN=$(aws lambda publish-layer-version \ --layer-name pyjwt-cryptography \ --zip-file fileb:///tmp/jwt-layer.zip \ --compatible-runtimes python3.12 \ --region $REGION \ --query 'LayerVersionArn' --output text) echo "Layer ARN: $LAYER_ARN" # Attach layer to Events Lambda aws lambda update-function-configuration \ --function-name ${STACK_NAME}-events \ --layers "$LAYER_ARN" \ --region $REGION
Now deploy the Lambda code:
# Events Lambda cd lambda-events zip -j /tmp/lambda-events.zip index.py aws lambda update-function-code \ --function-name ${STACK_NAME}-events \ --zip-file fileb:///tmp/lambda-events.zip \ --region $REGION cd .. # Worker Lambda cd lambda-worker zip -j /tmp/lambda-worker.zip index.py aws lambda update-function-code \ --function-name ${STACK_NAME}-worker \ --zip-file fileb:///tmp/lambda-worker.zip \ --region $REGION cd .. # Notify Lambda cd lambda-notify zip -j /tmp/lambda-notify.zip index.py aws lambda update-function-code \ --function-name ${STACK_NAME}-notify \ --zip-file fileb:///tmp/lambda-notify.zip \ --region $REGION cd ..
Or use the deploy script which handles the stack, code, and routing config in one shot:
./deploy.sh <agent-space-id> <app-id> <app-password> <tenant-id> [stack-name]
4e. Seed the Routing Config
After deploying, seed the initial routing config so the bot knows which space to route to:
BUCKET=$(aws cloudformation describe-stacks \ --stack-name $STACK_NAME \ --query 'Stacks[0].Outputs[?OutputKey==`StateBucketName`].OutputValue' \ --output text --region $REGION) cat > /tmp/space-routing.json << EOF { "default": "<your-agent-space-id>", "teams": {}, "channels": {} } EOF aws s3 cp /tmp/space-routing.json "s3://${BUCKET}/config/space-routing.json" --region $REGION
Don't forget to update <your-agent-space-id>
4f. Verify
# Should be ~5-9KB (not 300 bytes — that means you still have the placeholder) aws lambda get-function-configuration \ --function-name ${STACK_NAME}-events --region $REGION \ --query '{CodeSize: CodeSize, LastModified: LastModified}'
Step 5: Create and Sideload the Teams App
The Manifest
Create manifest.json:
{ "$schema": "https://developer.microsoft.com/en-us/json-schemas/teams/v1.16/MicrosoftTeams.schema.json", "manifestVersion": "1.16", "version": "1.0.1", "id": "YOUR-APP-ID-HERE", "developer": { "name": "DevOps Agent", "websiteUrl": "https://aws.amazon.com", "privacyUrl": "https://aws.amazon.com/privacy", "termsOfUseUrl": "https://aws.amazon.com/terms" }, "name": { "short": "DevOps Agent", "full": "AWS DevOps Agent for Teams" }, "description": { "short": "Chat with AWS DevOps Agent", "full": "Chat with AWS DevOps Agent directly in Teams. Start investigations, ask questions, get results." }, "icons": { "outline": "outline.png", "color": "color.png" }, "accentColor": "#FF9900", "bots": [ { "botId": "YOUR-APP-ID-HERE", "scopes": ["personal", "team", "groupChat"], "commandLists": [ { "scopes": ["personal"], "commands": [ { "title": "investigate", "description": "Start an investigation on an AWS resource" }, { "title": "help", "description": "Show help and usage tips" } ] } ] } ], "permissions": ["messageTeamMembers"], "validDomains": [] }
Replace YOUR-APP-ID-HERE (both id and botId) with your actual Azure App ID.
Build the App Package
You need two icon files (32x32 outline, 192x192 color) and the manifest:
zip devops-agent-teams.zip manifest.json color.png outline.png
Enable Sideloading
- Go to https://admin.teams.microsoft.com (sign in as admin)
- Navigate to Teams apps → Setup policies → Global
- Toggle "Upload custom apps" to ON
- Save (may take a few minutes to propagate)
Upload the App
- Open https://teams.microsoft.com
- Click Apps (left sidebar) → Manage your apps → Upload an app
- Select "Upload a custom app"
- Upload your
devops-agent-teams.zip - Click Add
Step 6: Test It
Find "DevOps Agent" in your Teams chat and send a message:
Check my Lambda error rates in us-east-1
You should see a response within 10–15 seconds.
Multi-Space Routing
Here's where it gets interesting. You've got one bot, one Azure registration — but maybe your team has multiple DevOps Agent spaces (one per environment, one per team, whatever). I didn't want to register a separate bot for each. So I built routing.
The idea: a single Teams bot routes messages to different agent spaces based on which Teams channel or team the message comes from. The routing config lives in S3, gets cached for 60 seconds for performance, and you can manage it entirely from Teams itself — no AWS console needed.
How Routing Works
When a message arrives, the Events Lambda resolves the target agent space with this priority:
- Channel match — if this specific channel is registered, route there
- Team match — if the parent team is registered, route there
- Default — fall back to the default space (the
AgentSpaceIdparameter from the stack)
If a channel isn't registered and there's no team-level or default match, the bot replies with a prompt listing available spaces and how to register.
Routing Config Format
The config lives at s3://<state-bucket>/routing-config.json:
{ "defaultSpaceId": "abc12345-def6-7890-ghij-klmnopqrstuv", "routes": [ { "type": "channel", "teamsId": "19:abc123@thread.tacv2", "spaceId": "space-prod-11111111", "label": "#prod-incidents" }, { "type": "channel", "teamsId": "19:def456@thread.tacv2", "spaceId": "space-staging-22222222", "label": "#staging-debug" }, { "type": "team", "teamsId": "team-guid-here", "spaceId": "space-platform-33333333", "label": "Platform Team" } ], "cacheTtlSeconds": 60 }
Each route maps a Teams channel or team ID to a DevOps Agent space. The label is just for humans reading the config — the bot uses teamsId for matching.
Self-Service Commands
You manage routing directly from Teams. No need to touch S3 manually:
| Command | What it does |
|---|---|
/register <space-id> | Registers the current channel (or team) to route to <space-id> |
/unregister | Removes the routing for the current channel/team |
/status | Shows the current routing config — which space this channel hits, the priority chain, and all registered routes |
The /register command detects whether you're in a channel or a DM and creates the appropriate route type. If you run it in a channel, it creates a channel-level route. From a team-level context, it creates a team route.
Why This Matters
One bot, one Azure registration, one CloudFormation stack — but your production team's channel talks to the prod agent space, staging goes somewhere else, and the platform team has their own. No duplicate infrastructure. The IAM policy uses agentspace/* so the Lambda can call any space in the account without redeployment.
What It Costs (AWS Side Only)
Cost estimate based on 100 chat messages/day and 10 investigations/day (~3,000 messages and 300 investigations per month):
Infrastructure Costs (Estimated Only, may vary based on the use case)
| Service | Calculation | Monthly Cost |
|---|---|---|
| Lambda — Events | 3,000 invocations × 256 MB × 2s avg = 1,500 GB-s | $0.03 |
| Lambda — Worker | 3,000 invocations × 256 MB × 60s avg = 45,000 GB-s | $0.75 |
| Lambda — Notify | 300 invocations × 256 MB × 5s avg = 375 GB-s | $0.01 |
| API Gateway HTTP | 3,000 requests × $1.00/million | $0.003 |
| S3 | ~10,000 PUTs + GETs (sessions, thread maps, routing) | $0.05 |
| Secrets Manager | 1 secret + ~6,000 API calls (cached, so ~100 cold starts) | $0.40 |
| EventBridge | 300 events/month × $1.00/million | $0.00 |
| CloudWatch Logs | ~2 GB ingested (3 Lambdas logging) | $1.00 |
| Azure Bot (F0 free tier) | Unlimited messages on free SKU | $0.00 |
| Infrastructure Subtotal | $2.24 |
Security Posture
Every incoming request is validated inside the Events Lambda using Microsoft's RS256-signed JWT tokens — signature, audience, issuer, and expiry are all checked before any business logic runs. Credentials live in Secrets Manager, the S3 bucket blocks all public access, and each Lambda has its own least-privilege IAM role.
For additional hardening based on your organization's requirements, consider enabling AWS WAF on the API Gateway (rate limiting, request size filtering), adding an API Gateway JWT Authorizer to reject unauthenticated requests before they reach Lambda, setting up CloudWatch alarms on 4xx spikes, or enabling automatic secrets rotation. Use the AWS Pricing Calculator to estimate the cost impact of WAF or other additions before enabling them.
Wrapping Up
The whole setup takes about 45 minutes. Most of that time is Azure Portal clicking (app registration, bot resource, Teams channel). The AWS infrastructure deploys in under two minutes.
The integration uses three Lambda functions, one API Gateway, one S3 bucket, one secret, one EventBridge rule, and one Azure Bot (free tier).
- Tags
- AWS DevOps Agent
- Language
- English
Relevant content
asked 3 years ago
asked 5 months ago
