Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
50 changes: 32 additions & 18 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# M365 Inbox Agent for Azure Functions (Python) [![Python](https://img.shields.io/badge/Python-3.13-blue.svg)](https://www.python.org/downloads/) [![Open in GitHub Codespaces](https://github.com/codespaces/badge.svg)](https://github.com/codespaces/new?repo=Azure-Samples%2Fm365-inbox-serverless-agent-python)

An AI agent that triages your Microsoft 365 inbox: it escalates urgent mail to Teams, drafts replies, and emails you a daily briefing β€” all following rules you write in plain markdown.
An AI agent that triages your Microsoft 365 inbox: it escalates urgent mail to Teams, drafts replies, and emails you a daily briefing - all following rules you write in plain markdown.

**Powered by Azure Functions and Microsoft 365 connectors. You bring the markdown logic and a little Python for your rules.**

Expand All @@ -9,10 +9,10 @@ Run it locally in minutes against sample data, point it at your real inbox, then

## What it does for you

- 🚨 **Escalate** β€” VIP / urgent mail is posted to your Teams channel, with an @mention.
- βœ‰οΈ **Reply** β€” action-required mail gets a grounded draft reply.
- πŸ“‹ **Brief** β€” a daily summary of what matters lands in your inbox.
- πŸ’¬ **Chat** β€” ask read-only questions about your recent mail.
- πŸ“‹ **Daily Briefing**: a daily summary of what matters lands in your inbox.
- 🚨 **Escalate**: VIP / urgent mail is posted to your Teams channel, with an @mention.
- βœ‰οΈ **Reply**: action-required mail gets a grounded draft reply.
- πŸ’¬ **Chat**: ask read-only questions about your recent mail.

β†’ Full walkthroughs in [docs/use-cases.md](docs/use-cases.md).

Expand All @@ -21,7 +21,7 @@ Run it locally in minutes against sample data, point it at your real inbox, then
- Python 3.13+. Easiest install: [uv](https://docs.astral.sh/uv/), then `uv python install 3.13`.
- [Azure Functions Core Tools v4](https://learn.microsoft.com/en-us/azure/azure-functions/functions-run-local) β‰₯ 4.12.0 (the v5 preview is not yet compatible).
- [Azure Developer CLI (`azd`)](https://learn.microsoft.com/en-us/azure/developer/azure-developer-cli/).
- An Azure subscription. `azd provision` (Quickstart step 2) creates the Microsoft Foundry model deployment the agents need β€” required even for the offline path.
- An Azure subscription. `azd provision` (Quickstart step 2) creates the Microsoft Foundry model deployment the agents need - required even for the offline path.
- For real M365 (or `azd up`): permission to authorize Microsoft 365 connectors, plus the `connector-namespace` CLI extension:

**macOS / Linux:**
Expand Down Expand Up @@ -54,7 +54,7 @@ Use the [Core Tools v4 install guide](https://learn.microsoft.com/en-us/azure/az

### 2. Get the resources you need

`azd provision` creates the Foundry model deployment the agents require (needed even offline); `hydrate` copies the settings into `local.settings.json`. No API keys β€” managed identity throughout.
`azd provision` creates the Foundry model deployment the agents require (needed even offline); `hydrate` copies the settings into `local.settings.json`. No API keys - managed identity throughout.

**macOS / Linux:**
```bash
Expand All @@ -68,7 +68,7 @@ azd provision
.\infra\scripts\hydrate-local-settings.ps1
```

> Yes β€” even the offline, sample-data run needs this one-time `azd provision`, because the agents call a Foundry model. It provisions the model deployment only; it never touches your inbox.
> Yes - even the offline, sample-data run needs this one-time `azd provision`, because the agents call a Foundry model. It provisions the model deployment only; it never touches your inbox.

### 3. Run the local client

Expand All @@ -80,43 +80,57 @@ uv run python chat.py # terminal C

(These commands work identically on macOS, Linux, and Windows.)

> πŸͺŸ **Windows local dev:** If `uv run func start` fails with `ModuleNotFoundError: No module named 'azure_functions_agents'`, the Microsoft Store python.exe alias on your `PATH` is shadowing the venv Python. See [Troubleshooting: Windows local dev](docs/troubleshooting.md#windows-local-dev) for the fix β€” you'll need to remove the Store Python and disable App execution aliases in Windows Settings.
> πŸͺŸ **Windows local dev:** If `uv run func start` fails with `ModuleNotFoundError: No module named 'azure_functions_agents'`, the Microsoft Store python.exe alias on your `PATH` is shadowing the venv Python. See [Troubleshooting: Windows local dev](docs/troubleshooting.md#windows-local-dev) for the fix - you'll need to remove the Store Python and disable App execution aliases in Windows Settings.

### 4. Try it (offline, safe)

Pick **1**, **2**, or **3**. The client shows a 🟑 Offline banner and runs every agent in **DRY RUN** against `sample-data/inbox/*.json`: it produces the full deliverable as text and calls no connector, so nothing is ever sent. Pick **5** to chat with the sample inbox.

Want it to act on your real inbox while still local? See [Go live with real M365](docs/configuration.md#go-live-with-real-m365).

### 5. Deploy to Azure, then try it again
### 5. Go live (set your recipient and Teams target)

Set who real mail and Teams posts go to, **before** you deploy. Without these, the deployed agents stay in DRY RUN (the chat client's doctor banner will tell you).

```bash
azd up
azd env set MAILBOX_OWNER_EMAIL you@your-tenant.com # required for LIVE mail
azd env set TEAMS_TEAM_ID <team-id> # optional, enables Teams alerts
azd env set TEAMS_CHANNEL_ID <channel-id> # optional, enables Teams alerts
```

> **Known issue (point-in-time):** `azd up` may currently fail at the deploy step on Python 3.13 β€” the Flex remote build uses Python 3.11.8 ([Azure/azure-dev#8538](https://github.com/Azure/azure-dev/issues/8538)). Simple workaround: [docs/deploy-python-313.md](docs/deploy-python-313.md). This note can be removed once the bug is fixed.
Get the Teams ids by opening the target channel in Teams β†’ β‹― β†’ **Get link to channel** (the URL contains both ids), or via [docs/configuration.md](docs/configuration.md).

Now `inbox-triage` fires automatically on every new email β€” no client, no waiting. Send yourself a message, then watch your Teams channel (VIP / incident) or your inbox (replies). Tail the live trace with `azd monitor --logs`.
> Already deployed with placeholders? Set them now and re-run `azd up` (or just `azd provision`) to push the new values to the Function App.

### Clean up
### 6. Deploy to Azure, then try it again

```bash
azd down --purge
azd up
```

> Hitting an error? See [docs/troubleshooting.md](docs/troubleshooting.md).
> **Known issue (point-in-time):** `azd up` may currently fail at the deploy step on Python 3.13 - the Flex remote build uses Python 3.11.8 ([Azure/azure-dev#8538](https://github.com/Azure/azure-dev/issues/8538)). Simple workaround: [docs/deploy-python-313.md](docs/deploy-python-313.md). This note can be removed once the bug is fixed.

Now `inbox-triage` fires automatically on every new email - no client, no waiting. Send yourself a message, then watch your Teams channel (VIP / incident) or your inbox (replies). Tail the live trace with `azd monitor --logs`.

## <img src="https://raw.githubusercontent.com/microsoft/fluentui-system-icons/main/assets/Shield/SVG/ic_fluent_shield_24_regular.svg" width="20" align="center"> Make it yours
### 7. <img src="https://raw.githubusercontent.com/microsoft/fluentui-system-icons/main/assets/Shield/SVG/ic_fluent_shield_24_regular.svg" width="20" align="center"> Make it yours

- ✍️ Edit `skills/vip-rules.md` to set your VIPs, what to skip, and what escalates to Teams.
- πŸ” The `weekly-rule-suggestions` agent proposes tuning that you approve by hand.
- πŸ”’ Use this repo as a **private template** before adding real rules or tenant data.

β†’ Full guide: [docs/customize.md](docs/customize.md).

### 8. Clean up

```bash
azd down --purge
```

> Hitting an error? See [docs/troubleshooting.md](docs/troubleshooting.md).

## How it works (the short version)

This is an Azure Functions app on the serverless agents runtime. Each agent is a markdown file (`*.agent.md`) that reasons over your rules in `skills/*.md`; a small `tools/match_rule.py` adds deterministic classification. Microsoft 365 actions go through Entra-authorized MCP connectors β€” no app secrets, managed identity end to end.
This is an Azure Functions app on the serverless agents runtime. Each agent is a markdown file (`*.agent.md`) that reasons over your rules in `skills/*.md`; a small `tools/match_rule.py` adds deterministic classification. Microsoft 365 actions go through Entra-authorized MCP connectors - no app secrets, managed identity end to end.

β†’ Deeper dives: [How it works](docs/how-it-works.md) Β· [Configuration & deployment](docs/configuration.md) Β· [Customize](docs/customize.md) Β· [Troubleshooting & reference](docs/troubleshooting.md)

Expand Down
4 changes: 2 additions & 2 deletions azure.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -33,11 +33,11 @@ hooks:
postdeploy:
posix:
shell: sh
run: ./infra/scripts/configure-trigger.sh
run: ./infra/scripts/hydrate-local-settings.sh && ./infra/scripts/configure-trigger.sh
interactive: true
continueOnError: false
windows:
shell: pwsh
run: ./infra/scripts/configure-trigger.ps1
run: ./infra/scripts/hydrate-local-settings.ps1; if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }; ./infra/scripts/configure-trigger.ps1
interactive: true
continueOnError: false
50 changes: 48 additions & 2 deletions chat.py
Original file line number Diff line number Diff line change
Expand Up @@ -125,6 +125,43 @@ def chat_url(agent_name: str) -> str:
return url


def _host_kind() -> str:
"""'cloud' if BASE_URL points to *.azurewebsites.net, else 'local'."""
return "cloud" if "azurewebsites.net" in BASE_URL else "local"


def _host_reachable(timeout: float = 2.0) -> bool:
"""True if the Functions host at BASE_URL responds at all (any HTTP code).

Connection refused / DNS failure / TLS error => host is not reachable.
"""
probe = f"{BASE_URL}/admin/host/status"
try:
urllib.request.urlopen(probe, timeout=timeout)
return True
except urllib.error.HTTPError:
return True
except Exception:
return False


def _print_host_unreachable_hint() -> None:
"""Shared 'host is down' guidance β€” pointed at local and cloud paths."""
if _host_kind() == "cloud":
print(" AGENT_URL points to the cloud Function App but it did not respond.")
print(" Re-run: `source ./infra/scripts/use-cloud-host.sh` (refreshes the key),")
print(" or check the Function App is running in the Azure portal.")
return
print(" The local Functions host is not running. Either:")
print(" A) Run it locally (3 terminals):")
print(" terminal A: azurite --silent --location ~/.azurite")
print(" terminal B: uv run func start")
print(" terminal C: uv run python chat.py")
print(" B) Or call the deployed cloud Function App from this shell:")
print(" source ./infra/scripts/use-cloud-host.sh")
print(" uv run python chat.py")


_TEAMS_TRIGGERING_RX = re.compile(r"urgent|p1\b|incident|escalat|outage", re.IGNORECASE)
_SKIP_RX = re.compile(r"^\s*fyi\b|newsletter", re.IGNORECASE)

Expand Down Expand Up @@ -632,7 +669,8 @@ def trigger_agent(agent_name: str, mode_icon: str, mode_label: str = "") -> None
return
except Exception as exc:
print(f"\nError calling {agent_name} /chat: {exc}")
print("Is the Functions host running with `uv run func start`?\n")
_print_host_unreachable_hint()
print()
return

elapsed = time.monotonic() - start
Expand All @@ -652,9 +690,16 @@ def mark(key: str) -> str:

print("\nConfig readiness")
print("================")
kind = _host_kind()
up = _host_reachable()
host_label = f"{'🟒' if up else 'πŸ”΄'} {kind} ({BASE_URL}) {'reachable' if up else 'NOT reachable'}"
print(f" {'Functions host':<22} {host_label}")
for key in ("OUTLOOK_MCP_ENDPOINT", "MAILBOX_OWNER_EMAIL", "TEAMS_TEAM_ID", "TEAMS_CHANNEL_ID"):
print(f" {key:<22} {mark(key)}")
print()
if not up:
_print_host_unreachable_hint()
print()
print(f" {'Agent':<26} {'Mode':<9} {'Sends email':<13} Posts Teams")
print(f" {'-' * 26} {'-' * 9} {'-' * 13} {'-' * 11}")
for label, name in (
Expand Down Expand Up @@ -883,7 +928,8 @@ def chat_with_inbox() -> None:
continue
except Exception as exc:
print(f"\n Error: {exc}")
print(" Is the Functions host running with `uv run func start`?\n")
_print_host_unreachable_hint()
print()
continue

response_text = (result.get("response") or "").strip()
Expand Down
6 changes: 5 additions & 1 deletion infra/scripts/hydrate-local-settings.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -78,4 +78,8 @@ $settings = [ordered]@{

$settings | ConvertTo-Json -Depth 5 | Set-Content -Path local.settings.json -Encoding UTF8
Write-Host "Wrote local.settings.json -- provider=foundry, model=$($envVars['FOUNDRY_MODEL'])"
Write-Host "Run 'func start' to start the host."
Write-Host ""
Write-Host "Next: run these in three separate terminals from the project root:"
Write-Host " azurite --silent --skipApiVersionCheck --location .azurite # terminal A"
Write-Host " uv run func start # terminal B"
Write-Host " uv run python chat.py # terminal C"
6 changes: 5 additions & 1 deletion infra/scripts/hydrate-local-settings.sh
Original file line number Diff line number Diff line change
Expand Up @@ -42,4 +42,8 @@ EOF

chmod 600 local.settings.json
echo "Wrote local.settings.json β€” provider=foundry, model=${FOUNDRY_MODEL}"
echo "Run 'func start' to start the host."
echo ""
echo "Next: run these in three separate terminals from the project root:"
echo " azurite --silent --skipApiVersionCheck --location .azurite # terminal A"
echo " uv run func start # terminal B"
echo " uv run python chat.py # terminal C"
24 changes: 24 additions & 0 deletions infra/scripts/use-cloud-host.ps1
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
# Dot-source (do not run) this file:
# . ./infra/scripts/use-cloud-host.ps1
# It sets $env:AGENT_URL + $env:FUNCTION_KEY so `uv run python chat.py` calls
# the deployed Function App in Azure instead of the local Functions host
# (skips needing `azurite` + `uv run func start` in two extra terminals).

$ErrorActionPreference = "Stop"

$uri = azd env get-value SERVICE_API_URI 2>$null
if (-not $uri) { Write-Error "no azd env β€” run 'azd up' first"; return }
$rg = azd env get-value RESOURCE_GROUP 2>$null
if (-not $rg) { Write-Error "RESOURCE_GROUP not in azd env"; return }
$app = azd env get-value AZURE_FUNCTION_APP_NAME 2>$null
if (-not $app) { Write-Error "AZURE_FUNCTION_APP_NAME not in azd env"; return }

$key = az functionapp keys list -g $rg -n $app --query functionKeys.default -o tsv 2>$null
if (-not $key) { Write-Error "could not fetch function key β€” try 'az login'"; return }

$env:AGENT_URL = $uri
$env:FUNCTION_KEY = $key
Write-Host "AGENT_URL=$uri"
$tail = $key.Substring([Math]::Max(0, $key.Length - 4))
Write-Host "FUNCTION_KEY=***$tail"
Write-Host "chat.py will now call the deployed Function App ($app)."
26 changes: 26 additions & 0 deletions infra/scripts/use-cloud-host.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
#!/usr/bin/env bash
# Source (do not execute) this file:
# source ./infra/scripts/use-cloud-host.sh
# It exports AGENT_URL + FUNCTION_KEY so `uv run python chat.py` calls the
# deployed Function App in Azure instead of the local Functions host
# (skips needing `azurite` + `uv run func start` in two extra terminals).

_die() { echo "use-cloud-host: $*" >&2; return 1 2>/dev/null || exit 1; }

command -v azd >/dev/null || _die "azd not on PATH" || return 1
command -v az >/dev/null || _die "az not on PATH" || return 1

_uri=$(azd env get-value SERVICE_API_URI 2>/dev/null) || _die "no azd env β€” run 'azd up' first" || return 1
_rg=$(azd env get-value RESOURCE_GROUP 2>/dev/null) || _die "RESOURCE_GROUP not in azd env" || return 1
_app=$(azd env get-value AZURE_FUNCTION_APP_NAME 2>/dev/null) || _die "AZURE_FUNCTION_APP_NAME not in azd env" || return 1

_key=$(az functionapp keys list -g "$_rg" -n "$_app" --query functionKeys.default -o tsv 2>/dev/null) \
|| _die "could not fetch function key β€” try 'az login'" || return 1
[ -n "$_key" ] || _die "function key empty β€” host may still be starting" || return 1

export AGENT_URL="$_uri"
export FUNCTION_KEY="$_key"
echo "AGENT_URL=$AGENT_URL"
echo "FUNCTION_KEY=***${_key: -4}"
echo "chat.py will now call the deployed Function App ($_app)."
unset _uri _rg _app _key
Loading