feat(claude-in-office): Outlook manifest support

build-manifest.mjs now takes a host argument (office | outlook). The
outlook host fetches manifest-outlook-3p.xml, which uses Microsoft's
MailApp schema and cannot share a file with the TaskPaneApp manifest
Excel/Word/PowerPoint use.

- URL_SLOTS regexes are now /g so all three Outlook taskpane URL entries
  (FormSettings + V1_0 + V1_1 Resources) receive the config params, not
  just the first match.
- New graph_client_id key for orgs that bring their own Entra app for
  Microsoft Graph instead of consenting to Anthropic's multi-tenant app.
- aws_* keys with the outlook host fail fast: Bedrock is not currently
  supported for Outlook.
- consent.md gains the Outlook Graph admin-consent URL (Mail.ReadWrite,
  Calendars.Read, People.Read, User.Read, offline_access) and the
  own-Entra-app alternative.
- manifest.md and setup.md document the host arg, the per-host file
  split, and route admins to the consent step before generating the
  Outlook manifest.
This commit is contained in:
Vivek Nair
2026-05-03 21:33:32 -04:00
parent bb4a2b3e53
commit a7d983a364
4 changed files with 109 additions and 21 deletions
@@ -1,5 +1,5 @@
---
description: Azure admin consent URL — one-time tenant approval for Entra SSO
description: Azure admin consent URLs — one-time tenant approval for Entra SSO and Outlook Graph access
---
# Azure admin consent
@@ -33,4 +33,28 @@ az ad sp show --id c2995f31-11e7-4882-b7a7-ef9def0a0266 --query appId -o tsv
```
If that returns the same GUID, the service principal exists in their tenant —
consent worked. If it errors with "does not exist", consent didn't complete.
consent worked. If it errors with "does not exist", consent didn't complete.
## Outlook — Microsoft Graph consent
**Only needed when deploying the Outlook manifest.** Separate from `entra_sso`
above; required even if `entra_sso` is off.
Claude for Outlook reads mail and calendar through Microsoft Graph. The Graph
token stays in the user's Outlook client and is never sent to the gateway or to
Anthropic, so this consent is the same regardless of which cloud serves the
model. A Global Admin opens the URL, clicks Accept, done.
```
https://login.microsoftonline.com/organizations/v2.0/adminconsent?client_id=c2995f31-11e7-4882-b7a7-ef9def0a0266&scope=https://graph.microsoft.com/Mail.ReadWrite%20https://graph.microsoft.com/Calendars.Read%20https://graph.microsoft.com/People.Read%20https://graph.microsoft.com/User.Read%20offline_access&redirect_uri=https://pivot.claude.ai/auth/callback
```
Without this, every user hits a "Need admin approval" wall the first time
Claude tries to read mail.
**If their policy forbids consenting to a third-party app:** they can register
their own single-tenant Entra app with the same delegated Graph permissions
(Mail.ReadWrite, Calendars.Read, People.Read, User.Read, offline_access), grant
admin consent on it, and pass its client ID as `graph_client_id` when generating
the Outlook manifest. Same data flow; approval lives under their app instead of
Anthropic's.
@@ -4,9 +4,16 @@ description: Generate the add-in manifest XML with your cloud config baked in
# Generate add-in manifest
The script fetches the canonical manifest from `pivot.claude.ai/manifest.xml`
and appends your config as URL query parameters. The add-in reads them at
startup.
The script fetches the canonical manifest and appends your config as URL query
parameters. The add-in reads them at startup. Outlook uses a separate template
because Microsoft's `MailApp` schema is distinct from the `TaskPaneApp` schema
Excel/Word/PowerPoint share, so ask which apps they're deploying and generate
one file per host.
| Host arg | Apps | Template |
|---|---|---|
| `office` | Excel, Word, PowerPoint | `pivot.claude.ai/manifest.xml` |
| `outlook` | Outlook (mail + calendar) | `pivot.claude.ai/manifest-outlook-3p.xml` |
## Keys by cloud
@@ -20,6 +27,22 @@ Prompt only for the keys their cloud path needs. Don't ask for all eight.
| Gateway | `gateway_url` `gateway_token` `gateway_auth_header` `gateway_api_format` |
| Gateway (`gateway_api_format=vertex`) | also `gcp_project_id` `gcp_region` |
Amazon Bedrock is **not currently supported for the `outlook` host**; the script
exits with an error if you pass `aws_*` keys with `outlook`.
## Outlook — Microsoft Graph
Outlook reads the user's mailbox and calendar via Microsoft Graph, which
requires a one-time tenant-wide admin consent regardless of which cloud serves
the model. Run [consent](consent.md#outlook--microsoft-graph-consent) before
deploying — otherwise every user hits "Need admin approval" on first open.
If their policy forbids consenting to a third-party app, prompt for
`graph_client_id` (their own single-tenant Entra app's client ID with
Mail.ReadWrite, Calendars.Read, People.Read, User.Read, offline_access
delegated permissions and admin consent granted). Otherwise leave it unset and
the add-in uses Anthropic's multi-tenant app.
## Entra SSO
`entra_sso=1` makes the add-in acquire an Entra ID token at startup. Set it
@@ -149,11 +172,16 @@ template's version as-is.
## Run
```bash
node "${CLAUDE_PLUGIN_ROOT}/scripts/build-manifest.mjs" manifest.xml \
node "${CLAUDE_PLUGIN_ROOT}/scripts/build-manifest.mjs" office manifest.xml \
gcp_project_id=<value> \
gcp_region=<value> \
auto_connect=0 \
...
# and if they're also deploying Outlook:
node "${CLAUDE_PLUGIN_ROOT}/scripts/build-manifest.mjs" outlook manifest-outlook.xml \
<same provider keys as above> \
graph_client_id=<value> # only if NOT using Anthropic's app via the consent URL
```
The script validates key names (unknown keys fail hard) and shape-hints values
@@ -173,3 +201,4 @@ match the symptom below. Edit `manifest.xml` directly, then re-validate.
| "An add-in with this ID already exists" | Replace the text inside `<Id>` with a fresh UUID. The template carries the marketplace install's ID. |
| Re-upload accepted but nothing changes | M365 caches by ID + version. Edit `<Version>` to a higher fourth segment (e.g. `1.0.0.9``1.0.0.10`) and re-validate. |
| Only want Excel (not PowerPoint) | Remove `<Host>` elements for `Presentation`. **Two parallel lists:** the top-level `<Hosts>` uses `Name="Presentation"`, the one under `<VersionOverrides>` uses `xsi:type="Presentation"` — both must go or the manifest is inconsistent. The `xsi:type` block is multi-line, delete the whole `<Host xsi:type="Presentation">...</Host>`. |
| Only want Excel/PPT, not Outlook | Nothing to remove — Outlook is a separate file. Just don't generate it. |
+21 -4
View File
@@ -1,5 +1,5 @@
---
description: Setup wizard — provision Vertex/Bedrock/gateway, admin consent, generate manifest
description: Setup wizard — provision Vertex/Bedrock/Foundry/gateway, admin consent, generate manifest(s)
---
# Claude in Office — Direct Cloud Setup
@@ -47,6 +47,21 @@ Bedrock and per-user config (bootstrap endpoint or extension attrs) need
`entra_sso=1` — the add-in acquires the user's Entra ID token to authenticate
those flows. See the Entra SSO section in [manifest](manifest.md).
## Step 1b — Which Office apps?
Ask: **Excel/Word/PowerPoint, Outlook, or both?** Outlook is a separate
manifest and has one extra prerequisite.
If they're deploying Outlook:
- **Bedrock is not currently supported for Outlook.** If they picked `bedrock`
in Step 1, Outlook is off the table for now — generate only the `office`
manifest.
- **Microsoft Graph admin consent is required.** Run
[consent](consent.md#outlook--microsoft-graph-consent) — a Global Admin opens
one URL and clicks Accept. Do this before generating the manifest so you can
ask whether they're using Anthropic's app (no `graph_client_id` needed) or
their own Entra app (capture `graph_client_id`).
Branch to the matching section below.
---
@@ -250,16 +265,18 @@ Write the split into the setup log so Step 4 and Step 5 each know their subset.
## Step 4 — Generate the manifest
Read `${CLAUDE_PLUGIN_ROOT}/commands/manifest.md` and follow it with the
**org-wide** values from Step 3. The command wraps:
**org-wide** values from Step 3. Generate one file per host from Step 1b:
```bash
node "${CLAUDE_PLUGIN_ROOT}/scripts/build-manifest.mjs" manifest.xml <key>=<value> ...
node "${CLAUDE_PLUGIN_ROOT}/scripts/build-manifest.mjs" office manifest.xml <key>=<value> ...
node "${CLAUDE_PLUGIN_ROOT}/scripts/build-manifest.mjs" outlook manifest-outlook.xml <key>=<value> ...
```
Then validate:
Then validate each:
```bash
npx -y office-addin-manifest validate manifest.xml
npx -y office-addin-manifest validate manifest-outlook.xml
```
## Step 5 — Per-user config
@@ -2,15 +2,19 @@
// Fetches the canonical add-in manifest and writes a customized copy with your
// org's config baked into the taskpane URL as query parameters.
//
// Usage: node build-manifest.mjs <out.xml> key=value [key=value ...]
// Example: node build-manifest.mjs acme.xml gcp_project_id=acme gcp_region=us-east5
// Usage: node build-manifest.mjs <office|outlook> <out.xml> key=value [key=value ...]
// Example: node build-manifest.mjs office acme.xml gcp_project_id=acme gcp_region=us-east5
import { writeFileSync } from "node:fs";
const MANIFEST_URL = process.env.MANIFEST_URL || "https://pivot.claude.ai/manifest.xml";
const MANIFESTS = {
office: "https://pivot.claude.ai/manifest.xml", // Excel + Word + PowerPoint (TaskPaneApp)
outlook: "https://pivot.claude.ai/manifest-outlook-3p.xml", // Outlook (MailApp — separate schema)
};
// The manifest has two URL slots Office reads from; both must carry the same params.
const URL_SLOTS = [/(<SourceLocation\s+DefaultValue=")([^"]+)(")/, /(id="Taskpane\.Url"\s+DefaultValue=")([^"]+)(")/];
// Every URL slot Office reads from must carry the same params. Outlook's MailApp
// schema repeats Taskpane.Url across V1_0 and V1_1 VersionOverrides, hence /g.
const URL_SLOTS = [/(<SourceLocation\s+DefaultValue=")([^"]+)(")/g, /(id="Taskpane\.Url"\s+DefaultValue=")([^"]+)(")/g];
// Recognized config keys. `pattern` is a shape hint — mismatches warn but don't block
// (your infra may look different). `secret` keys warn louder: the manifest is an
@@ -34,6 +38,10 @@ const KEYS = {
pattern: /^[A-Za-z0-9]{20,}$/,
hint: "From Azure Portal → your Foundry resource → Keys and Endpoint → KEY 1",
},
graph_client_id: {
pattern: /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i,
hint: "Entra app (client) ID for Microsoft Graph — Outlook only; omit to use Anthropic's multi-tenant app via the admin consent URL",
},
gateway_url: { pattern: /^https:\/\//, hint: "HTTPS base URL" },
gateway_token: { pattern: /./, hint: "gateway API key", secret: true },
gateway_auth_header: { pattern: /^(x-api-key|authorization)$/i, hint: "auth header scheme (default: x-api-key)" },
@@ -66,12 +74,20 @@ const KEYS = {
const NEEDS_ENTRA = ["aws_role_arn", "graph_client_id", "entra_scope"];
async function main() {
const [out, ...pairs] = process.argv.slice(2);
if (!out || pairs.length === 0) {
console.error("Usage: node build-manifest.mjs <out.xml> key=value [key=value ...]");
const [host, out, ...pairs] = process.argv.slice(2);
const manifestUrl = process.env.MANIFEST_URL || MANIFESTS[host];
if (!manifestUrl || !out || pairs.length === 0) {
console.error("Usage: node build-manifest.mjs <office|outlook> <out.xml> key=value [key=value ...]");
console.error(`Keys: ${Object.keys(KEYS).join(", ")}`);
process.exit(1);
}
if (host === "outlook" && pairs.some((p) => p.startsWith("aws_"))) {
console.error("error: Amazon Bedrock (aws_role_arn/aws_region) is not currently supported for Outlook");
process.exit(1);
}
if (host !== "outlook" && pairs.some((p) => p.startsWith("graph_client_id="))) {
console.warn("note: graph_client_id only applies to Outlook; it has no effect in the office manifest");
}
const params = new URLSearchParams();
for (const p of pairs) {
@@ -102,18 +118,20 @@ async function main() {
// URLSearchParams joins with `&`; XML attribute values need it escaped.
const qs = params.toString().replaceAll("&", "&amp;");
const res = await fetch(MANIFEST_URL);
if (!res.ok) throw new Error(`fetch ${MANIFEST_URL}: ${res.status} ${res.statusText}`);
const res = await fetch(manifestUrl);
if (!res.ok) throw new Error(`fetch ${manifestUrl}: ${res.status} ${res.statusText}`);
let xml = await res.text();
for (const slot of URL_SLOTS) {
slot.lastIndex = 0;
if (!slot.test(xml)) throw new Error(`manifest missing expected URL slot: ${slot.source}`);
slot.lastIndex = 0;
// The template URL already carries ?m=<tag> — append with & not a second ?
xml = xml.replace(slot, (_, pre, url, post) => pre + url + (url.includes("?") ? "&amp;" : "?") + qs + post);
}
writeFileSync(out, xml);
console.log(`Wrote ${out} (params: ${params})`);
console.log(`Wrote ${out} (${host}, params: ${params})`);
}
main().catch((err) => {