LOG 010.2: Connecting OpenClaw to Microsoft Teams on a Headless Linux Server
This is a companion entry to the Nine / 9OS series. The series up to this point established the systemd service topology and the M365 identity layer. This entry builds the bot infrastructure: the Azure App Registration, the Cloudflare Tunnel for inbound webhook delivery, and the Teams app manifest that brings OpenClaw online as a bot you can DM and channel-message from anywhere in your tenant.
Series hub: LOG 010: Building Nine — A Headless AI Agent Operating Layer for Paragon9
01. THE SYSTEMS SOURCE: TWO DIRECTIONS, TWO PROBLEMS
Teams connectivity has a shape that's easy to miss until you've already broken it twice.
There are two independent data paths. OpenClaw needs to authenticate outbound to Azure and Microsoft's Bot Framework — that's credentials, tenant IDs, client secrets. Teams needs to deliver messages inbound to your machine — webhook events fired from Microsoft's servers every time someone sends the bot a message. These are different directions, different infrastructure concerns, and they require different solutions.
On a laptop running a local dev environment, neither problem is visible. The browser handles auth. ngrok or devtunnel handles the tunnel. Neither survives a headless deployment. The browser is gone. DevTunnel's auth loop breaks on servers without a GUI. ngrok's free tier doesn't offer persistent subdomains — your webhook URL changes every restart, which means updating the Azure Bot messaging endpoint manually every time the machine reboots.
The solutions I landed on after working through this: Cloudflare Tunnel for inbound delivery (no auth loop, no token expiry, runs cleanly as a systemd service), and manual Azure portal steps from your main workstation for everything that requires a browser. Every decision documented below came from a real failure at the step before it.
The Insight: On a headless server, you're not just running a bot — you're maintaining two persistent infrastructure connections simultaneously. Get both right before you assume the integration is working. A bot that can authenticate but never receives messages looks identical to a bot that's broken in a more fundamental way.
02. THE BUILD SOURCE: THE DEPLOYMENT ROADMAP
Prerequisites
- OpenClaw installed and running as a systemd service (LOG 010.1)
- SSH access to the Linux server
- A Microsoft Azure account (free tier is sufficient)
- A Cloudflare account (free) with a domain managed by Cloudflare DNS
- Node.js 22+ on the server (
node --versionto verify) - A Windows or Mac machine for browser-based auth steps
Phase 1: Verify OpenClaw Is in PATH
OpenClaw may be installed in a non-standard location that your SSH session doesn't resolve automatically. Check first:
which openclaw
openclaw --version
If you get command not found, locate the binary:
find ~ -name "openclaw.mjs" 2>/dev/null
find ~ -name "openclaw" -type f 2>/dev/null
⚠️ Gotcha #1 — OpenClaw installed outside the system PATH
If OpenClaw was installed as a Node.js project (e.g. at
~/ai-bridge/openclaw/openclaw.mjs), the system won't find it without a symlink. Create one so the rest of this guide's commands work cleanly:mkdir -p ~/.local/bin ln -s ~/ai-bridge/openclaw/openclaw.mjs ~/.local/bin/openclaw chmod +x ~/ai-bridge/openclaw/openclaw.mjs echo 'export PATH="$HOME/.local/bin:$PATH"' >> ~/.bashrc source ~/.bashrc which openclaw openclaw --version
Phase 2: Azure App Registration
Why from your main machine? Headless Linux servers can't open a browser for Microsoft's OAuth flow. All Azure portal work happens on your main workstation. Paste the resulting credentials onto the server when you're done.
On your main machine, navigate to portal.azure.com.
Step 1 — Register the application:
Search "Entra ID" → App registrations → New registration
- Name:
your-bot-name(e.g.9claw-bot) - Supported account types: Single tenant
- Redirect URI: leave blank
Click Register.
On the Overview page, copy and store both values:
- Application (client) ID
- Directory (tenant) ID
Step 2 — Create a client secret:
Left sidebar → Certificates & secrets → New client secret
Set a description and expiry (12 or 24 months). Click Add.
⚠️ Gotcha #2 — Client secret is only shown once
Copy the Value immediately — not the Secret ID. Once you navigate away from this page, it is gone permanently. There is no recovery. Store it in your password manager before doing anything else.
Step 3 — Create the Azure Bot:
Search "Azure Bot" in the portal → Create
- Bot handle: your bot name (globally unique across Azure)
- Pricing tier: F0 (free)
- Microsoft App ID: Use existing app registration
- App ID: paste your Application (client) ID
Click Review + Create → Create.
Step 4 — Enable the Teams channel:
Open the Azure Bot resource once deployed → Left sidebar → Channels → Microsoft Teams → Accept terms → Apply.
Note: The messaging endpoint field is blank here. You'll return to fill it in after the tunnel is running — you need the tunnel URL first.
Phase 3: Cloudflare Tunnel
Cloudflare Tunnel is the right tool for inbound webhook delivery on a private machine. DevTunnel has persistent auth loop issues on headless servers. ngrok's free tier generates a new URL on every restart, which means your Azure Bot messaging endpoint is stale after every reboot. Cloudflare Tunnel creates a named, persistent subdomain backed by a credential file with no expiry — it runs as a systemd service and never needs re-auth.
Install cloudflared on the server:
curl -L https://github.com/cloudflare/cloudflared/releases/latest/download/cloudflared-linux-amd64.deb -o cloudflared.deb
sudo dpkg -i cloudflared.deb
cloudflared --version
Authenticate with Cloudflare:
cloudflared tunnel login
This prints a URL. Open it on your main machine and log into your Cloudflare account. This writes a persistent certificate to ~/.cloudflared/cert.pem — no expiry, no re-auth ever needed.
Create a named tunnel:
cloudflared tunnel create your-tunnel-name
Copy the tunnel UUID from the output. A credentials file is created at ~/.cloudflared/<tunnel-uuid>.json.
Create the config file:
nano ~/.cloudflared/config.yml
tunnel: <tunnel-uuid>
credentials-file: /home/<your-username>/.cloudflared/<tunnel-uuid>.json
ingress:
- service: http://localhost:3978
Route a DNS subdomain to the tunnel:
cloudflared tunnel route dns your-tunnel-name bot.yourdomain.com
Your bot's public URL will be https://bot.yourdomain.com.
Test the tunnel manually before persisting it:
cloudflared tunnel run your-tunnel-name
Look for Registered tunnel connection and Ready to accept connections. Ctrl+C once confirmed.
Install cloudflared as a systemd service:
sudo nano /etc/systemd/system/cloudflared-bot.service
[Unit]
Description=Cloudflare Tunnel for OpenClaw Teams Bot
After=network-online.target
Wants=network-online.target
[Service]
Type=simple
User=<your-username>
ExecStart=/usr/bin/cloudflared tunnel run your-tunnel-name
Restart=always
RestartSec=10
[Install]
WantedBy=multi-user.target
sudo systemctl daemon-reload
sudo systemctl enable cloudflared-bot
sudo systemctl start cloudflared-bot
sudo systemctl status cloudflared-bot
Phase 4: Set the Azure Bot Messaging Endpoint
Back in portal.azure.com:
Open your Azure Bot resource → Configuration → set Messaging endpoint to:
https://bot.yourdomain.com/api/messages
Click Apply.
⚠️ Gotcha #3 — Bot appears connected in Azure but Teams messages never arrive
This is the most common failure mode after initial setup. Azure accepts the messaging endpoint configuration without validation — if the URL is wrong (wrong domain, missing
/api/messagessuffix, tunnel not running), Teams drops messages silently with no error in the portal. Verify the exact endpoint URL and confirm the tunnel service is running before testing.
Phase 5: Configure OpenClaw
nano ~/.openclaw/openclaw.json
Add the Teams channel block using the three credential values from Phase 2:
{
"channels": {
"msteams": {
"enabled": true,
"appId": "<Application (client) ID>",
"appPassword": "<Client Secret Value>",
"tenantId": "<Directory (tenant) ID>",
"dmPolicy": "allowlist",
"allowFrom": ["<your-entra-aad-object-id>"],
"webhook": {
"port": 3978,
"path": "/api/messages"
}
}
}
}
⚠️ Gotcha #4 — Finding your AAD Object ID
The
allowFromfield takes AAD Object IDs — not display names or UPNs. Display names and UPNs can change. Object IDs don't. In Entra ID → Users → find your account → copy the Object ID from the Identity section. Common mistake: copying the App registration's Object ID instead of your user account's.
Restart OpenClaw and verify the channel registered:
sudo systemctl restart openclaw
openclaw channels status
Expected output:
- Microsoft Teams default: enabled, configured, running
Phase 6: Build and Sideload the Teams App Package
Teams requires a signed app manifest ZIP before the bot becomes available to chat with. The teams.cli tool can handle this, but it requires browser-based auth that frequently fails on headless servers with restrictive org tenants. Building manually takes five minutes and puts you in direct control of every field.
Install build dependencies:
sudo apt-get install -y imagemagick zip
Create the app directory and manifest:
mkdir -p ~/teams-app
nano ~/teams-app/manifest.json
Paste the following, replacing the three placeholder values with your Application (client) ID:
{
"$schema": "https://developer.microsoft.com/en-us/json-schemas/teams/v1.23/MicrosoftTeams.schema.json",
"manifestVersion": "1.23",
"version": "1.0.0",
"id": "<Application (client) ID>",
"name": {
"short": "YourBotName",
"full": "YourBotName — OpenClaw AI Agent"
},
"developer": {
"name": "Your Company",
"websiteUrl": "https://yourwebsite.com",
"privacyUrl": "https://yourwebsite.com/privacy",
"termsOfUseUrl": "https://yourwebsite.com/terms"
},
"description": {
"short": "OpenClaw AI agent in Microsoft Teams",
"full": "Your OpenClaw AI agent, accessible via Microsoft Teams."
},
"icons": {
"outline": "outline.png",
"color": "color.png"
},
"accentColor": "#000000",
"bots": [
{
"botId": "<Application (client) ID>",
"scopes": ["personal", "team", "groupChat"],
"isNotificationOnly": false,
"supportsCalling": false,
"supportsVideo": false,
"supportsFiles": true
}
],
"webApplicationInfo": {
"id": "<Application (client) ID>"
},
"authorization": {
"permissions": {
"resourceSpecific": [
{ "name": "ChannelMessage.Read.Group", "type": "Application" },
{ "name": "ChannelMessage.Send.Group", "type": "Application" },
{ "name": "Member.Read.Group", "type": "Application" },
{ "name": "Owner.Read.Group", "type": "Application" },
{ "name": "ChannelSettings.Read.Group", "type": "Application" },
{ "name": "TeamMember.Read.Group", "type": "Application" },
{ "name": "TeamSettings.Read.Group", "type": "Application" },
{ "name": "ChatMessage.Read.Chat", "type": "Application" }
]
}
}
}
⚠️ Gotcha #5 — Three fields, all must match, all get wrong
The
id,bots[].botId, andwebApplicationInfo.idfields must all contain the same Application (client) ID. They look redundant. Teams validates all three independently. If any one differs, the manifest upload fails with a validation error that doesn't tell you which field is the problem.
Generate the icons:
cd ~/teams-app
# Color icon — 192x192, customize hex to match your brand
convert -size 192x192 xc:#000000 \
-fill white -pointsize 52 -gravity center \
-annotate 0 "P9" \
color.png
# Outline icon — 32x32
convert -size 32x32 xc:transparent \
-fill white -pointsize 10 -gravity center \
-annotate 0 "P9" \
outline.png
Build the ZIP:
cd ~/teams-app
zip -j ~/teams-app.zip manifest.json color.png outline.png
# Verify — should show exactly three files, no subdirectories
unzip -l ~/teams-app.zip
Copy to your main machine:
On Windows (PowerShell):
scp user@<server-ip>:~/teams-app.zip $env:USERPROFILE\Desktop\teams-app.zip
On Mac/Linux:
scp user@<server-ip>:~/teams-app.zip ~/Desktop/teams-app.zip
Sideload into Teams:
- Open Microsoft Teams on your main machine
- Apps in the left sidebar → Manage your apps (bottom left)
- Upload an app → Upload a custom app
- Select
teams-app.zipfrom your Desktop - Click Add
Search for your bot name in Teams and send it a DM.
Phase 7: Verify End to End
On the server, watch the logs live while you send a test message in Teams:
openclaw channels logs --follow
A successful incoming message looks like:
info {"name":"msteams"} received message
⚠️ Gotcha #6 —
dropping dm (not allowlisted)in the logsYour AAD Object ID isn't in the
allowFromarray inopenclaw.json. ThedmPolicy: allowlistconfig is working as intended — it's rejecting an Object ID it doesn't recognize. Double-check your Object ID in Entra ID → Users. This error means the message reached OpenClaw successfully; the allowlist is the only thing stopping it.
⚠️ Gotcha #7 — Channel shows
enabled, configuredbut notrunningThis is an
openclaw.jsonsyntax error. The config file failed to parse — OpenClaw loaded but couldn't initialize the Teams channel. Validate the JSON, fix the syntax, and restart:python3 -m json.tool ~/.openclaw/openclaw.json sudo systemctl restart openclaw openclaw channels status
⚠️ Gotcha #8 —
webApplicationInfo.Id already in useon manifest uploadThe bot app is still installed in another Teams context — another team, another chat, or a previous sideload session. Find and uninstall it first: in Teams, go to Apps → Manage your apps, locate the existing install, and remove it. Wait about five minutes for Teams to release the registration before trying again.
03. THE LEGACY SOURCE: TEAMS CONNECTIVITY IS LIVE
At this point, the full stack is running end to end:
✔ OpenClaw PATH verified — openclaw available as a system command
✔ Azure App Registration — tenant ID, client ID, and client secret issued
✔ Azure Bot created with Teams channel enabled
✔ Cloudflare Tunnel running as a systemd service — persistent inbound webhook delivery
✔ Azure Bot messaging endpoint pointed at https://bot.yourdomain.com/api/messages
✔ OpenClaw configured with Teams credentials and DM allowlist
✔ Teams app manifest built manually, sideloaded, and verified
✔ End-to-end message flow confirmed in logs
What you built here is infrastructure, not a demo. The tunnel survives reboots. The bot stays registered. The webhook endpoint doesn't change. The DM allowlist means only explicitly approved identities can send the agent commands — not the whole tenant.
The discipline of building it manually — without teams.cli, without ngrok, without shortcuts — means you understand every moving part. When something breaks six months from now, you won't be staring at a black box. You'll know which of the four layers to check first.
The Lesson: Every shortcut in a headless deployment comes due eventually. Browser-based auth loops on a server you can't physically touch. Temporary tunnel URLs in a messaging endpoint you have to update manually after every restart. Tools that work on your laptop and fail silently in production. The slightly longer path — persistent credentials, named tunnels, manual manifests — is the only path that holds.
Security Notes
- Cloudflare sees plaintext request content — they terminate TLS at the edge. For an internal tool this is acceptable. For sensitive client data, consider Tailscale Funnel or a static IP with direct HTTPS instead.
- Client secret in openclaw.json — lock down the file permissions:
chmod 600 ~/.openclaw/openclaw.json - dmPolicy: allowlist — keep this set and use explicit AAD Object IDs rather than display names, which can change.
- Client secret expiry — set a calendar reminder to rotate the secret before it expires (12–24 months). Rotating requires updating
appPasswordinopenclaw.jsonand restarting OpenClaw.
Maintenance
Both services start automatically on reboot. Verify with:
sudo systemctl status openclaw
sudo systemctl status cloudflared-bot
Check channel health:
openclaw channels status --probe
Update the Teams manifest (e.g. to rename the bot):
Edit ~/teams-app/manifest.json, re-zip, and sideload — remove the old app in Teams first to avoid conflicts.
Rotate the client secret:
- Generate a new secret in Entra ID → App registrations → Certificates & secrets
- Update
appPasswordin~/.openclaw/openclaw.json sudo systemctl restart openclaw- Delete the old secret in Entra ID
[SYSTEM NOTES]
- PATH fix: Symlink
openclaw.mjsto~/.local/bin/openclawon minimal installs — don't assume the binary is in a standard location - Azure portal work: All browser-based Azure steps must be done from a main workstation — headless servers cannot complete OAuth flows
- Client secret: Copy the Value (not the Secret ID) immediately on creation — shown once, no recovery
- Tunnel choice: Cloudflare Tunnel preferred over devtunnel (auth loop issues) and ngrok free tier (ephemeral URLs) for on-prem headless servers
- Messaging endpoint: Set after the tunnel is running; format is
https://<subdomain>/api/messages— Azure does not validate this on save - allowFrom: Takes AAD Object IDs — not display names or UPNs, which can change
- Manifest IDs:
id,bots[].botId, andwebApplicationInfo.idmust all match — three fields, one value, validation fails if they diverge - teams.cli: Unreliable on headless servers with restrictive org tenants — build the manifest ZIP manually
Stay Resilient.
— Greg Scott Kirk (Tek)
Part of the Nine / 9OS series.
← Previous: LOG 010.1: Architecting a Headless AI Bridge — Standardizing OpenClaw under Systemd
→ Next: LOG 010.3: Setting Up a Dedicated M365 Identity for Nine
This is the architecture Paragon9 installs for mid-market firms — modernized, documented, and built to run without its operator. If you want it running in your business, the conversation is free.