19 March 2026 · 9 min read
How I deployed an enterprise AI agent on Azure with Teams, email, and SharePoint
I recently built and deployed an AI agent for our consultancy that lives in Microsoft Teams, reads email and calendar, accesses SharePoint documents, and runs on Claude Sonnet as its brain. It runs on a single Azure VM and has been in production for a few weeks now.
This post is the full walkthrough. The architecture, the phases, the things that broke, and what I'd do differently. All the commands and config files are in the [companion repo](https://github.com/jdiekman/openclaw-azure-setup).
The runtime is [OpenClaw](https://openclaw.ai), an open-source agent framework that connects to external services through plugins. I've been running a version of it locally for months. This was the exercise of taking that and making it production-grade in the Microsoft stack.
The architecture
Everything runs on a Standard_B2ms in Azure Australia East: 2 vCPU, 8 GB RAM, roughly $104 AUD/month including static IP and premium SSD.
`
User --> Microsoft Teams --> Azure Bot Service --> Caddy (HTTPS :443)
|
+------------------------+------------------------+
| |
/api/messages everything else
msteams plugin OpenClaw gateway
port 3978 port 3000
| |
Bot Framework Agent runtime
webhook handler (Claude Sonnet)
| |
Teams DMs Graph API / GitHub /
& channels Anthropic (Mail, Calendar,
SharePoint)
`
Caddy sits at the edge, handles SSL termination, and routes traffic. Bot Framework webhook POSTs hit port 3978. Everything else goes to the main gateway on port 3000. That separation matters and it's where I made my first mistake, which I'll get to.
Prerequisites
Before starting:
- Azure subscription with Entra ID Global Admin access
- A spare M365 E3 (or equivalent) license for the agent's identity
- Anthropic API key
- Azure CLI and M365 CLI installed locally
- A domain you can point at the VM's IP
- DNS provider access for creating A records
Phase 1-2: VM and base setup
Create a resource group, spin up Ubuntu 24.04. First security step: lock down the NSG immediately. SSH (port 22) restricted to your current public IP only. HTTPS (port 443) is the only other inbound rule. Everything else denied.
On the VM: Node.js 22 LTS, headless Chromium for browser-based skills, PM2 for process management, Caddy as the reverse proxy.
Caddy is the right call here because it handles Let's Encrypt automatically. No certbot cron jobs, no manual renewals. It just works.
Create a dedicated ai-agent Linux user. OpenClaw runs under this account, isolated from the admin account you SSH in with.
Phase 3: Giving the agent an identity
The agent needs to exist as a real M365 user. Create an Entra ID user account, assign an M365 license (required for Teams and a mailbox), set usage location.
Then exclude this service account from MFA. Add it to the exclusion list on your existing Conditional Access policy. Without this step, the agent can't authenticate programmatically. This is the one that trips people up.
One thing I learned the hard way: the UPN (user principal name) you choose matters more than it seems. I changed mine three times before settling on the final format. Changing it later causes cascading issues with app registration and Exchange policies. Pick something sensible upfront and commit to it.
Phase 4: App registration and Graph API permissions
Create a single-tenant app registration with no redirect URIs. It's a daemon service, not a user-facing app. Generate a client secret with a 6-month expiry and store it somewhere safe immediately. You won't see it again.
The Graph API permissions I used at the application level:
- Mail.Read and Mail.Send for mailbox access
- Calendars.Read for the agent's calendar and the primary user's
- Sites.Read.All and Sites.ReadWrite.All for SharePoint
- Chat.ReadWrite.All for Teams interactions
- User.Read.All for profile lookups
- OnlineMeetings.Read.All for meeting details
- ChannelMessage.Read.All for Teams channel messages
Grant admin consent for each. The az ad app permission admin-consent command often fails silently, which is annoying. I found that granting permissions individually via the appRoleAssignments endpoint is more reliable. The companion repo has the exact commands.
One thing worth knowing: ChannelMessage.Send does not exist as an application permission. Teams message sending goes through Bot Framework, not Graph API directly.
Phase 5: SharePoint workspace
Set up a private SharePoint site as the agent's document workspace. Four libraries: Inbox (files dropped for the agent to process), Outbox (completed work), Reference (templates and guidelines that persist), Projects (working documents by client).
Disable external sharing, limit membership to yourself and the agent account.
Phase 6: Azure Bot registration
Register an Azure Bot resource. The F0 free tier works fine for a proof of concept. Enable the Microsoft Teams channel. The messaging endpoint is https://your-domain/api/messages.
Set msaAppType to SingleTenant and supply your tenant ID. Use the same app registration from Phase 4.
Phase 7: DNS and Caddy
Point a subdomain at the VM's static IP with an A record. Once DNS propagates, the Caddy config looks like this:
`
yourdomain.com {
handle /api/messages {
reverse_proxy localhost:3978
}
handle {
reverse_proxy localhost:3000
}
}
`
This is a critical detail I missed on the first pass: the msteams plugin runs a separate Express server on port 3978. It is not part of the main OpenClaw gateway on port 3000. Bot Framework webhook POSTs to /api/messages must reach port 3978. Everything else goes to the gateway. If you get this wrong, messages arrive but nothing responds, and the logs give you nothing useful.
Caddy provisions the SSL certificate automatically on the first request. No manual steps.
Phase 8: OpenClaw installation
Switch to the ai-agent user, install OpenClaw, run the onboard wizard. The model config matters here: anthropic/claude-sonnet-4-6, with the provider prefix. Without it, the routing fails.
Install the msteams plugin. Store environment variables in .env at /home/ai-agent/workspace/.env with chmod 600. Create a PM2 wrapper that sources the file and starts the gateway.
The workspace files (SOUL.md, IDENTITY.md, USER.md, TOOLS.md, HEARTBEAT.md) get injected into every system prompt turn. Keep them short. They define the agent's identity, behaviour rules, and tool config. Long files here burn tokens on every message.
Security decision worth calling out: I disabled ClawHub entirely. Workspace-only mode means the agent cannot install anything from external sources without me adding it manually.
Phase 9: Teams integration
Create a private Team, add the agent as a member, set up your channels. Build a Teams app manifest using schema version 1.17. Not newer. Newer versions hit parsing errors that the Teams client gives you no useful feedback on.
Upload the manifest via Teams Admin Center, not the Teams client directly. Sideloading failed for me every time. The Admin Center upload works reliably, but propagation to users can take up to 24 hours. Plan for that.
What broke
The crash loop
The msteams plugin entered an auto-restart cycle immediately after starting. Root cause: a bug in monitor.ts where the function returned after calling listen() instead of returning a long-lived promise. OpenClaw's lifecycle expects the provider to stay running, so when the promise resolved instantly, it treated that as "provider stopped" and restarted it. Each restart hit EADDRINUSE because the previous server was still bound to the port.
Fix: wrap the return in a new Promise() that only resolves when the HTTP server actually closes.
Duplicate messages
The agent was sending every response twice. Two problems compounding:
First: when Claude takes longer than about 15 seconds, Bot Framework sends the HTTP response and revokes the TurnContext proxy. The messenger's fallback to continueConversation() then re-sent everything.
Second: the activity ID dedup was keyed on req.body.id. Teams retries assign new IDs, so they bypassed it entirely.
Three patches fixed it: content-based dedup in monitor.ts keyed on sender:text, a config override in policy.ts so DMs respect the global replyStyle, and setting replyStyle: "top-level" in openclaw.json to force proactive messaging everywhere. Proactive messaging creates a fresh context not tied to the HTTP request lifecycle, which sidesteps the proxy revocation issue entirely.
The tsx cache
OpenClaw uses tsx to compile TypeScript plugins. Output is cached in /tmp/tsx-*. After editing any .ts source file, clear this cache or your changes will not take effect. This cost me more debugging time than I want to admit.
`bash
rm -rf /tmp/tsx-*
pm2 restart openclaw --update-env
`
Exchange application access policies
These require mail-enabled security groups, not regular Azure AD security groups. Use New-DistributionGroup -Type Security in Exchange Online PowerShell. Graph API and az ad group create will not work here.
Also: Connect-ExchangeOnline fails in elevated PowerShell sessions because of the WAM broker. Run it from a normal window.
Security
Beyond the basics (NSG lockdown, SSH keys only, secrets in chmod 600 files):
Exchange Application Access Policy scopes mail and calendar access to only the agent's mailbox and the primary user's. Without this, the app registration can read every mailbox in the tenant. That is not acceptable. ClawHub disabled. No external skill sources. SOUL.md rules. These enforce restrictions the permissions model can't, like not reading the primary user's email even though the Exchange policy technically allows it. Behavioural constraints on top of permission constraints. Bot Framework webhook is JWT-authenticated by the msteams plugin. Caddy just forwards traffic.Still pending: secrets should move to Azure Key Vault, and the Control UI needs to be blocked from public access via Caddy routing rules.
What's next
- Azure Key Vault for secret management
- GitHub integration via SSH keys, template repos, and OIDC federation for Actions deployments
- Meeting service: a sidecar that joins Teams meetings via ACS Call Automation, transcribes audio with Azure Speech, and feeds it to Claude
- Automated secret rotation before the 6-month client secret expires
Resources
- [Companion repo](https://github.com/jdiekman/openclaw-azure-setup) with all commands, config files, and the full deployment log
- [OpenClaw documentation](https://openclaw.ai)
- [Azure Bot Service documentation](https://learn.microsoft.com/en-us/azure/bot-service/)
- [Microsoft Graph API permissions reference](https://learn.microsoft.com/en-us/graph/permissions-reference)