Connect ThreadWeave to Exchange Online, SharePoint, and Microsoft Copilot via Microsoft Graph.
ThreadWeave has three M365 connectors that use app-only client credentials to read your organization's Microsoft 365 data. This guide walks through the Azure setup for each one.
| Connector | What It Does | Permission Needed |
|---|---|---|
| Teams Bot | Captures decisions from Teams conversations, sends capture notifications, handles opt-out and delete commands | RSC: ChatMessage.Read.Chat, ChannelMessage.Read.Group |
| Email Watcher | Monitors Exchange Online inboxes for unread emails, reconstructs threads, ingests into ThreadWeave | Mail.Read |
| SharePoint Watcher | Discovers SharePoint sites and monitors document libraries for changes | Sites.Read.All |
| Graph External Connector | Pushes ThreadWeave entries to Microsoft Graph so they appear in Copilot and Microsoft Search results | ExternalConnection.ReadWrite.OwnedBy |
ingest_graph_mail.py: browser sign-in with MFA, no client secret, no admin consent. It needs one small Entra step: an app registration with "Allow public client flows" enabled and the delegated Mail.Read permission (reuse ThreadWeave-GraphReader from this guide). Set THREADWEAVE_MAIL_CLIENT_ID and THREADWEAVE_MAIL_TENANT_ID and run python ingest_graph_mail.py --dry-run. Microsoft's own Azure CLI client does NOT work for this (AADSTS65002 — first-party clients are blocked for Mail.Read).
threadweave serve)We recommend two separate app registrations — different permissions, different security boundaries.
Used by the Email Watcher and SharePoint Watcher.
ThreadWeave-GraphReaderAfter creation, note these values:
Application (client) ID: _______________ Directory (tenant) ID: _______________
Now create a client secret and set permissions:
Mail.Read, Sites.Read.AllUsed by the Graph External Connector to sync ThreadWeave entries to Copilot.
Repeat the same registration steps, with these differences:
ThreadWeave-CopilotConnectorExternalConnection.ReadWrite.OwnedBy (Application)Set these before starting ThreadWeave:
# For Email Watcher + SharePoint Watcher export AZURE_TENANT_ID="your-tenant-id" export AZURE_CLIENT_ID="your-graphreader-client-id" export AZURE_CLIENT_SECRET="your-graphreader-secret" # For Graph External Connector (Copilot) export THREADWEAVE_GRAPH_TENANT_ID="your-tenant-id" export THREADWEAVE_GRAPH_CLIENT_ID="your-copilotconnector-client-id" export THREADWEAVE_GRAPH_CLIENT_SECRET="your-copilotconnector-secret"
On Windows PowerShell:
$env:AZURE_TENANT_ID = "your-tenant-id" $env:AZURE_CLIENT_ID = "your-graphreader-client-id" $env:AZURE_CLIENT_SECRET = "your-graphreader-secret"
Before running the connectors, verify your app registrations work:
from msal import ConfidentialClientApplication
import json, base64
app = ConfidentialClientApplication(
client_id="your-client-id",
client_credential="your-secret",
authority=f"https://login.microsoftonline.com/your-tenant-id",
)
result = app.acquire_token_for_client(scopes=["https://graph.microsoft.com/.default"])
if "access_token" not in result:
print(f"FAILED: {result.get('error_description', result)}")
else:
token = result["access_token"]
parts = token.split('.')
payload = base64.urlsafe_b64decode(parts[1] + '=' * (4 - len(parts[1]) % 4))
claims = json.loads(payload)
roles = claims.get('roles', [])
print(f"OK — roles: {roles}")
If roles is empty:
Xmf8Q~...Fetches unread emails from an Exchange Online inbox and reconstructs conversation threads:
uv run python -c "
import asyncio, os, sys
sys.path.insert(0, '.')
from threadweave.connectors.email.watcher import MailWatcher
async def test():
watcher = MailWatcher()
emails = await watcher.fetch_unread(
mailbox='user@your-tenant.com',
max_results=5,
)
print(f'Fetched {len(emails)} unread emails')
for e in emails:
print(f' {e.subject[:60]} — {e.sender_name}')
asyncio.run(test())
"
Expected: lists unread emails from the specified mailbox.
Discovers SharePoint sites in your tenant:
uv run python -c "
import asyncio, os, sys
sys.path.insert(0, '.')
from threadweave.connectors.sharepoint.watcher import GraphClient
async def test():
client = GraphClient()
sites = await client.list_sites()
print(f'Found {len(sites)} SharePoint sites')
for s in sites:
print(f' {s.display_name} — {s.web_url}')
asyncio.run(test())
"
Expected: lists SharePoint sites. If you get 400 Bad Request: Tenant does not have a SPO license, your tenant lacks SharePoint Online.
Creates an external connection and registers the ThreadWeave schema:
uv run python -c "
from threadweave.connectors.graph.connector import ThreadWeaveGraphConnector
connector = ThreadWeaveGraphConnector()
print(f'Configured: {connector.is_configured}')
# Create connection and register schema
result = connector.register_schema()
print(f'Schema registered: {result}')
# Verify connection
info = connector.get_connection()
print(f'Connection state: {info}')
"
Expected: connection created (201), schema registered. If schema registration fails with 400, your tenant may lack Graph Connectors licensing.
The Teams bot is distributed as an app package. Build it with the packaged CLI so every org gets a validated, deterministic zip:
uv run python -m threadweave.cli teams package \ --bot-id YOUR-BOT-APP-ID --version 1.0.1 # → dist/threadweave-teams-app-1.0.1.zip
Two ways to get the package into an org. For a pilot, upload it manually in the Teams admin center:
admin.teams.microsoft.com → Teams apps → Manage apps → Upload new app → Publish
For customer onboarding, publish it with one command. The admin signs in once with a device code, and the app lands in the org catalog pending approval:
uv run python -m threadweave.cli teams publish \ --package dist/threadweave-teams-app-1.0.1.zip # 1. Open https://microsoft.com/devicelogin # 2. Enter the code, sign in as a Teams admin # → Uploaded, then approve in admin.teams.microsoft.com → Publish
Setup notes for the publish command:
THREADWEAVE_PUBLISH_CLIENT_ID to an app registration with the delegated AppCatalog.Submit permission and public client flows enabledAZURE_TENANT_ID; the sign-in authority must be tenant-scoped, the generic common authority fails with AADSTS50059requiresReview=true; AppCatalog.Submit submits for review only, a direct upload returns 403 even for global admins| Error | Likely Cause | Fix |
|---|---|---|
AADSTS7000215: Invalid client secret | Used Secret ID instead of Value | Copy the Value field from Certificates & secrets, not the Secret ID GUID |
AADSTS700016: Application not found | Wrong tenant directory | Check top-right of Azure Portal for active directory. App registrations are per-tenant |
roles is empty in JWT | Delegated permissions used, or admin consent not granted | Switch to Application permissions tab, re-add, click "Grant admin consent" |
401 Unauthorized on mailbox | Mailbox not in tenant, or no Exchange Online license | Verify user has Exchange Online and is in the same tenant as the app registration |
400: Tenant does not have a SPO license | No SharePoint Online in tenant | Tenant needs SharePoint Online license. Bare Entra ID tenants don't include it |
500 on POST /external/connections | No Graph connectors license | Requires M365 E5 or Graph connectors add-on. Dev sandbox may not support it |
403 on upsert/delete | Schema not registered yet | Run register_schema() first — items can't be created without a schema |
AADSTS65002: Consent between first party application | Device-code flow used Microsoft's Azure CLI client ID | Use your own app registration (public client flows enabled) via THREADWEAVE_PUBLISH_CLIENT_ID |
AADSTS50059: No tenant-identifying information | Device-code authority used common instead of the tenant | Set AZURE_TENANT_ID so the authority is tenant-scoped |
403: Caller doesn't have permissions to publish to the Teams App catalog | Direct upload without review; AppCatalog.Submit submits for review only | Use ?requiresReview=true (the CLI does this automatically), then approve in the admin center |
409 AppDefinitionAlreadyExists | The same package version is already in the org catalog | Expected on re-publish; the CLI prints "Already published" and exits cleanly. Bump --version for updates |
/api/v1/audit/recent for connector activity--dry-run and small --max values before full ingestion