SDK / infra · #324 trending MCP repo · ↗ +5 stars this week
M365 Copilot Proxy
cramt/m365-copilot-proxy
OpenAI-compatible proxy for Microsoft 365 Copilot — use the Copilot licence you already have as a backend for pi, openclaw and other coding agents. Prompt-emulated tool calling, no extra licence, no MCP.
- Stars
- 169
- This week
- +5
- Forks
- 48
- Open issues
- 9
- Skills inside
- 1
Documentation
README
m365-copilot-proxy
Use Microsoft 365 Copilot as an LLM backend for OpenAI-compatible coding agents like pi and OpenClaw. Wraps M365 Copilot's WebSocket/SignalR API in an OpenAI-compatible interface with tool calling support.
Want the gory protocol details? See docs/m365-copilot-api.md — a full write-up of M365 Copilot's undocumented WebSocket API: auth, SignalR frames, tones/models, throttling, the "Disengaged" filter, and the Copilot Studio agent trick that makes tool calling work.
How it works
M365 Copilot uses a SignalR WebSocket protocol, not the OpenAI API. This project translates between the two:
- Standalone proxy — HTTP server with
/v1/chat/completionsand/v1/modelsendpoints. Works with any OpenAI-compatible client (pi, OpenClaw, etc.). - OpenClaw plugin — Config generator + setup CLI for OpenClaw's provider system.
Tool calling
M365 Copilot doesn't support OpenAI-style tool_calls natively. Instead, tools are
emulated via a Markdown-fence format (the JSON {"tool":...} format was removed —
it scored 0/5 on real agentic tasks; see hypotheses §9):
- Tool definitions are injected into the prompt as fenced templates inside a
<tools>block - The model emits a fenced tool call — a code block whose info-string is the tool name
(scalar args as
key: valueheader lines, one free-form body arg as the fence body,old/newedits as aider-styleSEARCH/REPLACEdiffs) - The proxy/handler parses that and converts it to OpenAI
tool_callsformat - Shell-routing (the key lever): M365's chat-tuned model won't "act as an agent" on
demand but will reflexively write a
```bashblock. When the toolset includes a shell tool (bash/shell/run/run_command/… — any name), the proxy injects "do the whole step by writing one```bashblock" framing and routes that block to the shell tool. This exploits the one agentic behavior Microsoft's system prompt permits, and is what turns 0/5 into real multi-turn loops (verified 9-tool-call bug fix). - Reliability comes from the Copilot Studio agent (below) + the fenced/shell framing — without the agent, M365 ignores tool instructions and answers in prose
Agent mode
On first use, the system creates a Copilot Studio agent with tool-calling instructions baked into its server-side system prompt. This is done via the PowerPlatform API:
- Discovers the environment URL via the BAP API (
api.bap.microsoft.com) - Creates a bot with instructions in the Copilot Studio
minimalBotsAPI - Publishes the bot to get a
TitleId - Uses the agent ID (
T_{titleId}.{botId}.gpt.default) in WebSocket chat requests - Caches the agent ID in
~/.config/opencode-m365/agent-id.json
Conversation reuse
Each agent session reuses the same M365 conversation (same sessionId + conversationId). The WebSocket reconnects per turn but M365 maintains server-side context. This saves quota — the 600 message limit applies per-conversation.
Packages
@m365-copilot/core — Shared: auth, WebSocket client, tool formatting, proxy server, agent management, session
├── @m365-copilot/proxy — Standalone HTTP proxy binary
└── @m365-copilot/openclaw-plugin — OpenClaw config generator + setup CLI + skill
Setup
Prerequisites
- Node.js 24+
- pnpm 10+
- A work or school (Entra ID) account with M365 Copilot Chat. Personal Microsoft accounts — Microsoft 365 Personal/Family, Outlook.com, copilot.microsoft.com — are not supported: the proxy talks to the enterprise M365 Copilot service, and the Office client it signs in as rejects personal accounts ("You can't sign in here with a personal account", #64). "Every account" elsewhere in this README means every work or school account, premium or not.
- A way to sign in, either:
- TOTP-based MFA with the base32 secret in hand — the automated login types the 6-digit code itself, so it needs the seed, not an app on your phone. See Getting the TOTP secret. This is the headless-friendly option; prefer it if your tenant allows it.
- or a display and 30 seconds — set
M365_ENABLE_INTERACTIVE_APPROVAL=1and sign in by hand once. Required if your tenant has no TOTP option (push-only MFA, FIDO2, Okta/Ping/Duo).
1. Install
git clone https://github.com/cramt/m365-copilot-proxy
cd m365-copilot-proxy
pnpm install
pnpm build
2. Configure credentials
Create ~/.config/opencode-m365/secrets.json:
{
"email": "you@company.com",
"password": "your-password",
"mfaSecret": "YOUR_TOTP_BASE32_SECRET"
}
mfaSecret is the base32 seed your authenticator app derives its 6-digit codes
from — not a code itself. It looks like JBSWY3DPEHPK3PXP: 16–32 characters, A–Z
and 2–7 only, no spaces.
Getting the TOTP secret
Already using a password manager for TOTP? Just read it back out. 1Password,
Bitwarden, KeePassXC, Aegis, Ente Auth and friends all keep the seed and will show it
on demand — open the item's one-time-password field and reveal it. You'll get either a
bare base32 string or an otpauth://totp/...?secret=JBSWY3DPEHPK3PXP&... URI; in the
second case the secret= parameter is the bit you want. No re-enrollment needed.
If the seed is trapped in Microsoft Authenticator, enroll a second method. That app deliberately never exposes it, and Microsoft's security-info page won't re-display the key after enrollment either — between them that's the only case where the seed is genuinely unrecoverable. Enroll a fresh entry and copy it on the way past:
- Go to https://aka.ms/mfasetup (My Account → Security info).
- Add sign-in method → Authenticator app.
- Click "I want to use a different authenticator app". This step is the one that matters — the default path assumes Microsoft Authenticator and registers a push-only method with no seed you can extract.
- At the QR-code screen, click "Can't scan image?". It reveals a Secret key — that's your base32 string.
- Generate a code from it to finish enrollment —
oathtool --totp -b <secret>, or paste the seed into your password manager — and enter that code.
Store it somewhere that will give it back (see above) so this is a one-time chore. The new entry sits alongside your existing sign-in methods; you don't have to remove Microsoft Authenticator.
If your tenant has no TOTP option
Plenty of tenants can't do the above, and then there is no seed to extract:
- the authenticator-app / software-OATH method is disabled by tenant policy;
- MFA is push / number-matching only, FIDO2, Windows Hello, or certificate-based;
- sign-in is federated to a third-party IdP (Okta, Ping, Duo) that owns the MFA step.
The stored-credentials flow above cannot work in those cases — the automated login has no code to type, and no amount of configuration fixes that.
Use interactive approval instead. Skip secrets.json entirely and set:
export M365_ENABLE_INTERACTIVE_APPROVAL=1
m365-proxy 4141
A real browser window opens; you complete SSO/MFA by hand exactly as you would in Outlook — push, FIDO2, Okta, whatever your tenant enforces. The proxy captures the resulting OAuth code and from then on refreshes tokens silently from the MSAL cache, so the window is a one-time cost, not a per-run prompt. It also kicks in when stored credentials exist but stop working (policy change, MFA method swap).
Contributed by @EatonWu. Two honest caveats:
- It is opt-in on purpose. Without the flag a headless host (systemd, CI, a second
PC) fails loudly instead of hanging on a window nobody can see. Set
M365_NO_INTERACTIVE=1to veto it outright. - The redirect capture and token exchange are the same ones the automated path uses in production, but nobody has yet run this end to end against a federated Okta/Ping/Duo tenant. If that's you, please report in #4 — working or not.
Not device code. The obvious headless alternative —
login.microsoft.com/devicewith a short code — is permanently dead here, not merely unimplemented. Initiation returns a valid code, but redeeming it fails withAADSTS7000218: Entra treats Microsoft's own Copilot client as confidential for that grant and demands aclient_secretonly Microsoft holds. No tenant admin can grant it. Measured, with the sign-in actually completed, in §13 H13.2.
First run
On first run, the system does an automated browser login (via Playwright/Chromium) to get OAuth tokens. After that, tokens refresh silently from the MSAL cache.
3. Use with pi (or any OpenAI-compatible agent)
Start the proxy:
m365-proxy 4143 # or: pnpm run proxy 4143
Point pi at it via ~/.pi/agent/models.json:
{
"providers": {
"m365": {
"baseUrl": "http://localhost:4143/v1",
"api": "openai-completions",
"apiKey": "not-needed",
"compat": {
"supportsDeveloperRole": false,
"supportsReasoningEffort": false,
"supportsUsageInStreaming": false
},
"models": [
{ "id": "gpt-5.5-think-deeper", "name": "M365 Copilot (GPT-5.5, recommended)" },
{ "id": "m365-copilot", "name": "M365 Copilot (Auto)" }
]
}
}
}
apiKeyis a placeholder. The proxy binds to localhost and validates no credential — the field exists only because the OpenAI client schema requires it.
To let the model see images — a screenshot, a chart the agent drew, a PNG it reads — add
"input": ["text", "image"] to each model entry. pi then sends them as image_url parts and the proxy
uploads them to M365 the way the web client does.
Then run pi (use gpt-5.5-think-deeper — the reliable tool-calling model — and keep the
toolset lean; M365 "disengages" on very large tool payloads, see
docs/m365-copilot-api.md):
pi --models "gpt-5.5-think-deeper" -p --tools read,list,edit,write "your task"
This is verified working end-to-end, including multi-tool calls and real file edits.
Long sessions. Every turn draws on the account's throttle budget, about 100 turns refilled at
~1.6 a minute. A busy pi session can run it dry, and M365 then refuses every turn for about an hour
(PerUserThrottled). Start the proxy with M365_AVOID_THROTTLING=1 (and M365_DEBUG=1, so it can
read the budget from its logs) and it slows the session down instead. Once the budget is down to a
reserve, it waits before each turn, inside the open stream so pi doesn't time out. See
M365_AVOID_THROTTLING.
4. Use with OpenClaw
# Configure and start in one command
m365-openclaw-setup --start
# Or configure only, then start separately
m365-openclaw-setup
m365-proxy 4141
The proxy uses session reuse and delta messages — follow-up turns only send new messages, saving M365 quota. New conversations are detected automatically when the message array shrinks or the first user message changes.
5. Use as standalone proxy
npx m365-proxy 4141
# or
pnpm run dev
Then point any OpenAI-compatible client at http://localhost:4141/v1.
Temporary chats
Proxy conversations use M365 temporary-chat mode by default
(disableMemory=1 on the authenticated Chathub URL), so they are not added to
the Copilot history sidebar while multi-turn context still works for the live
conversation. Set M365_SAVE_HISTORY=1 before starting the proxy only if you
deliberately want proxy chats saved in Copilot history.
6. Run on NixOS (systemd service)
The proxy is a Nitro service. The flake exposes a package (built from the workspace via pnpm2nix) and a NixOS module:
# flake.nix
{
inputs.m365.url = "github:cramt/m365-copilot-proxy";
outputs = { nixpkgs, m365, ... }: {
nixosConfigurations.myhost = nixpkgs.lib.nixosSystem {
modules = [
m365.nixosModules.default
{
services.m365-copilot-proxy = {
enable = true;
# JSON with { email, password, mfaSecret } — kept out of the Nix store,
# delivered via systemd LoadCredential. Manage with sops-nix/agenix.
secretsFile = "/run/secrets/m365-copilot.json";
# port = 4141; # default
# host = "127.0.0.1"; # default — do not expose; unauthenticated, paid account
# openFirewall = false;
};
}
];
};
};
}
The service runs as a hardened DynamicUser unit. Auth state (msal-cache.json,
agent-id.json) persists in /var/lib/m365-copilot-proxy; a fresh deploy self-bootstraps
via headless login using secretsFile + the bundled Chromium. To run the package directly
without NixOS: nix run github:cramt/m365-copilot-proxy -- 4141.
Available models
| Model ID | M365 Tone | Description |
|---|---|---|
gpt-6-think-deeper |
Gpt_6_Reasoning | GPT-6 reasoning. Needs a paid/premium Copilot seat (see below); 30/30 on the bench (agent-less, relay framing). Defaults to relay_batch: 20/20 at 2.1 turns per task (relay: 3.2), 10/10 driving real pi |
gpt-6-sol |
Gpt_6_Sol_Reasoning | GPT-6 Sol ("GPT 6.0 Sol" in the web UI). Works on every account, no paid seat needed; uses the tool agent only on a premium account (see below). Defaults to relay_batch: 40/40 on the bench with the agent, 60/60 without, 30/30 driving real pi, at 2.1 turns per bench task either way |
gpt-6.1-sol |
Gpt_61_Sol_Reasoning | GPT-6.1 Sol ("GPT-6.1 Sol" in the web UI). Works on every account, and uses the tool agent only on a premium account, like gpt-6-sol. Defaults to relay_batch: 40/40 on the bench, 30/30 driving real pi, at 2.7 turns per bench task with the agent and 2.2 without, and without the agent it keeps out of its sandbox, which gpt-6-sol doesn't always (see below) |
gpt-5.6-think-deeper |
Gpt_5_6_Reasoning | GPT-5.6 reasoning — 27/30 on the bench, tied with gpt-5.5-think-deeper |
gpt-5.6 / gpt-5.6-quick |
Gpt_5_6_Chat | GPT-5.6 fast ("GPT 5.6 Quick response" in the web UI). Weak at tool calling — 7/30 on the bench (see below) |
gpt-5.5-think-deeper |
Gpt_5_5_Reasoning | Recommended default for agents/tool-calling — 26/30 on the bench |
gpt-5.5 / gpt-5.5-quick |
Gpt_5_5_Chat | GPT-5.5 fast — 16/30 on the bench, the strongest benchmarked chat tone |
m365-copilot / auto |
magic | Auto-routing — high-variance at tool-calling (confabulates; see below) |
quick |
Gpt_5_5_Chat | Alias of gpt-5.5 (its old Gpt_Quick tone was retired — see below) |
think-deeper |
Gpt_5_5_Reasoning | Alias of gpt-5.5-think-deeper (its old Gpt_Reasoning tone was retired) |
claude / claude-sonnet / claude-sonnet-4.6 |
Claude_Sonnet | Real Anthropic Claude Sonnet 4.6 (agent-less path) — 78/90 on the bench with the relay framing (47/76 with baseline). Defaults to relay_batch: 40/40, and 21% fewer turns driving real pi (20/20). claude-sonnet-4.5 is kept as an alias |
claude-sonnet-5.5 |
Claude_Sonnet (paid scenario) | Claude Sonnet 5.5. Needs a paid/premium Copilot seat — same tone as above, the scenario picks the model — and has a separate quota: 80 turns a day, 150 a week (see below). Defaults to relay, inherited from Sonnet 5 (not benchmarked yet) |
claude-sonnet-5 |
Claude_Sonnet_5 (paid scenario) | Claude Sonnet 5. Needs a paid/premium Copilot seat; no separate quota. 27/30 on the bench with the relay framing (6/40 with baseline; see below) |
claude-sonnet-think-deeper |
Claude_Sonnet_Reasoning | Claude reasoning |
claude-opus / claude-opus-5.5 |
Claude_Opus (paid scenario) | Claude Opus 5.5. Needs a paid/premium Copilot seat and has a small separate quota: 40 turns a day (see below). 10/10 on the bench with relay; defaults to relay_batch. claude-opus-5 is kept as an alias |
claude-opus-4.8 |
Claude_Opus (included scenario) | Claude Opus 4.8. Premium accounts only, but no priority-access quota. With its default relay_batch framing and the tool agent: 30/30 on the bench at 2.5 turns per task, 10/10 driving real pi. claude-opus-4.5 is kept as an alias (it was Opus 4.5 until October 2026) |
gpt-5.4 / gpt-5.4-quick |
Gpt_5_4_* | GPT-5.4 |
gpt-5.3 / gpt-5.3-think-deeper |
Gpt_5_3_* | GPT-5.3 |
gpt-5.2 / gpt-5.2-think-deeper |
Gpt_5_2_* | GPT-5.2 |
Bench scores are 10 tasks × 3 reps with the confab-retry off (M365_NO_CONFAB_RETRY=1), so
every first-try give-up counts, including ones the proxy's retry would normally recover
(hypotheses §19). GPT-6's 24/30 was measured with the retry on, so it
isn't directly comparable.
Microsoft retired the
*_Quicktones;*_Chattones replaced them. Everyquick/*-quickmodel ID still works and resolves to the replacement*_Chattone (e.g.gpt-5.3-quick→Gpt_5_3_Chat), so existing configs need no change.
✅ For tool calling, use
gpt-5.5-think-deeper(the default when no model is sent). The current agent + fenced/shell-routing path makes this reasoning tone robust: 26/30 on the bench, tied withgpt-5.6-think-deeperand well ahead of every benchmarked chat tone (docs/hypotheses.md §19), and 100% compliance and solve across prompt/toolset sizes in the tool-call harness (§12.10/§12.11). The defaultm365-copilot(magic) tone is not reliable for tools — it confabulates ("I no longer have access to the filesystem tools") and solves ~0% of real tasks (§12.11); a proxy request with nomodelfield already defaults togpt-5.5-think-deeperfor this reason.⚠️ The older reasoning tones (
gpt-5.2/gpt-5.3/gpt-5.4*-think-deeper, baregpt-5.4) route through M365'sDeepLeopipeline, which meta-analyzes the injected prompt and can disengage from tools. Prefergpt-5.5-think-deeper. See docs/m365-copilot-api.md §5/§10.
GPT-5.6 chat (gpt-5.6 / gpt-5.6-quick) — newer, and worse at agentic work
The web client calls this one "GPT 5.6 Quick response", which is the shape to expect:
it is the chat sibling of gpt-5.6-think-deeper, not a reasoning tone, and it self-identifies
as "the GPT-5 chat model" — exactly as Gpt_5_5_Chat does. Neither tone will tell you which
generation is actually answering, so don't pick between them on self-report.
Pick it for chat, not for tools. A higher version number is not an upgrade here: on the
bench it solves 7/30, against 16/30 for gpt-5.5. Its low message count (39 vs 69)
is not efficiency: 22 of its 23 failures gave up before making a single tool call. See
hypotheses §19.
No entitlement needed. It reaches a model on the default OfficeWebIncludedCopilot
scenario, so the proxy sends nothing special for it. This is newly true: until recently the
tone deflected with the canned BotConnection apology on the included scenario while serving
normally under OfficeWebPaidCopilot — it was entitlement-gated like Opus, which
hypotheses §12.15 had misfiled as a permanently dead route.
See §18 for the correction.
GPT-6 (gpt-6-think-deeper) — entitlement, but no separate quota
It needs the paid scenario, exactly like Opus. On the default
OfficeWebIncludedCopilot the Gpt_6_Reasoning tone is accepted and then deflects with the
canned apology; under OfficeWebPaidCopilot it serves normally. The proxy sends the paid
scenario automatically whenever the resolved tone is Gpt_6_Reasoning, so nothing has to be
configured — but this is an entitlement, not a bypass, and a seat without paid/premium
access will see the apology no matter which scenario is requested.
It is not metered separately, and that is the part worth stating. The paid scenario is an entitlement gate; it does not imply a budget. GPT-6 has no priority-access allowance and is throttled by the same per-conversation cap and thread-rate governor as every other model, so none of the Opus advice about rationing turns applies.
Tool calls go without the tool agent, and with a relay framing. With the Copilot Studio
tool agent attached, Gpt_6_Reasoning doesn't serve at all (a canned apology, on every account),
so the proxy sends GPT-6 tool requests agent-less, as it does Claude's (#41). Agent-less, under the
baseline framing GPT-6 worked in M365's own code interpreter instead of calling your tools:
0/30 on the bench. Under relay it scored 30/30 (confab-retry off, 2026-10-01), and 5/5 driving
real pi. The default is now relay_batch, which asks it to put as much as it can into each block:
20/20 on the bench at 2.1 turns per task against relay's 3.2, and 10/10 through real pi at 3 turns
per run (hypotheses §25). M365_FORCE_AGENT=1 and M365_FRAMING_VARIANT still override both.
gpt-5.5-think-deeper remains the recommended default and the no-model fallback, because it needs
no entitlement: making GPT-6 the default would hand most seats a model they can't reach.
GPT-6 Sol (gpt-6-sol) — no entitlement, the agent only on premium
The web client calls it "GPT 6.0 Sol"; the tone is Gpt_6_Sol_Reasoning and it identifies
itself as the GPT-6 reasoning model. Unlike gpt-6-think-deeper it is not entitlement-gated: it
serves on the default included scenario on premium and non-premium accounts alike, so the proxy
sends nothing special for it.
The tool agent depends on the account. With the Copilot Studio tool agent attached, GPT-6 Sol
serves on a premium (paid Copilot) account and returns a canned apology on a non-premium one.
Nothing in the login token tells the two apart, so the proxy tries: the first tool request carries
the agent, and if it comes back as that dead route the proxy remembers it for the rest of the
process and re-sends the request without the agent. Your client never sees the failure; a
non-premium account spends one ~3 s turn per proxy start, which M365_FORCE_AGENT=0 avoids. A
premium account occasionally returns the same apology as a one-off; once the agent has answered,
the proxy treats that as a transient and retries instead of dropping the agent.
Without the agent it has a sandbox of its own. It runs bash in a remote machine (/mnt/data,
/home/oai), finds none of your files there, and asks you to upload them, or hands back a Teams link
to a file it made. Turning off M365's code interpreter (M365_NO_CODE_INTERPRETER=1) doesn't stop
it. The user-voice relay framing does: on the bench it solved 60/60 agent-less, against 0–6/10 for
every other framing, and 30/30 with the agent (the others 3–9/10). Its batching variant,
relay_batch, is the default on both paths: it keeps relay's wording and asks for one script per
turn, which cut the turns by a third on the bench (60/60 agent-less, 40/40 with the agent) and by a
fifth to a quarter in real pi (30/30 runs). It doesn't send GPT-6 Sol to its sandbox any more often
than relay (an earlier, smaller run suggested it did; the retest found no difference). Details:
hypotheses §23, §28.
GPT-6.1 Sol (gpt-6.1-sol) — GPT-6 Sol's routing, and the relay_batch framing
The web client calls it "GPT-6.1 Sol"; the tone is Gpt_61_Sol_Reasoning, and like GPT-6 Sol it
identifies itself as the GPT-6 reasoning model. It is routed exactly like gpt-6-sol: the default
included scenario, which serves it on premium and non-premium accounts alike, and the tool agent
only on a premium account, which the proxy learns from the first tool request (see above).
It has a separate allowance, which the proxy doesn't use. The paid scenario serves it too, but every paid turn spends a GPT-6.1 Sol allowance of 40 turns a day and 75 a week, the size of Opus 5.5's; included turns spend nothing, and a non-premium account can't use the paid scenario at all. So the proxy stays on the included one. Whether the paid scenario serves a newer model behind the same name is still open: the model can't tell you, since its self-description is M365's.
It defaults to relay_batch on both paths. Without the agent it has a sandbox like GPT-6 Sol's,
and the framings that put the instructions in a <system> block send it there: baseline solved
0/20 and minimal 2/20. The framings written as the user's own request kept it out entirely. As with
GPT-6 Sol, it went to the sandbox no more often under relay_batch than under relay (never, on the
bench), so here the turn saving decides: 40/40 on the bench against relay's 39/40 at 17% fewer turns
with the agent and 36% fewer without, and 30/30 runs driving real pi at 29% and 47% fewer turns (about
4 and 3 per run). Details: hypotheses §27.
Without the agent it matches GPT-6 Sol on the bench, but stays out of its sandbox. Side by side
on both non-premium accounts, the two solved the same tasks at the same turns under every framing:
relay_batch and honest everything (about 2.1 and 2.9 turns per task), baseline and minimal
nothing. The difference is the sandbox: told by the user's note that it's the wrong machine, GPT-6.1
Sol never went into it under relay_batch (0 of 80 bench tasks and pi runs so far), where GPT-6 Sol
still looks around in it on about a quarter of tasks — harmless, but on a non-premium account
gpt-6.1-sol is the tidier of the two. It also shows the included scenario's GPT-6.1 Sol isn't GPT-6
Sol under a new name. Details: hypotheses §29.
Sonnet 5 and 5.5 (claude-sonnet-5, claude-sonnet-5.5) — their own sandbox, and the relay framing
Claude_Sonnet is Sonnet 4.6 on the included scenario and Sonnet 5.5 on the paid one, and
Sonnet 5 has its own tone, Claude_Sonnet_5, which is likewise Sonnet 4.6 unless the scenario is
paid. So both need a paid/premium seat, like Opus. Microsoft made that switch on 2026-10-06: before
it, the paid Claude_Sonnet was Sonnet 5, which is why claude-sonnet-5 now maps to the new tone.
Sonnet 5.5 is metered: 80 turns a day, 150 a week, separately from Opus, one per turn (in a
coding loop, one per tool call). The proxy handles it like the Opus quota below:
usage.x_m365_sonnet55_daily_remaining / x_m365_sonnet55_weekly_remaining on every response, a
429 once it's used up, and an opt-in fallback, M365_SONNET_FALLBACK_MODEL (e.g. claude-sonnet-5,
which isn't metered). Sonnet 5 has no such quota.
Sonnet 5 arrives with its own tools (bash_tool, create_file, …) running in a remote
sandbox (/home/claude) that cannot see your files, and it reads the proxy's usual
<system>-tagged tool framing as a prompt injection — so with that framing it inspects its own
empty sandbox and reports that your files don't exist (6/40 on the bench). Sonnet 5.5 has the same
sandbox. The proxy therefore gives every Sonnet model a user-voice framing instead. Sonnet 5's
default is relay: a plain note
asking it to guide you through your terminal one command at a time, which also tells it the sandbox
is the wrong machine. That scores 27/30 on the bench (from 5/30) and solved 5/5 fix-bug runs
through real pi. Sonnet 4.6 beat baseline with it too (78/90 vs 47/76), and now defaults to
relay_batch, the same note asking it to put as much as it can into each block: same solves, 21%
fewer turns through real pi (39/39). On Sonnet 5 that saved only 6% in pi, so it stays on relay.
Sonnet 5.5 starts on relay too, until it has been benchmarked.
Override with M365_FRAMING_VARIANT as usual. Details: hypotheses §21, §25, §26.
Opus (claude-opus, claude-opus-4.8) — two models, one with a small quota
The Claude_Opus tone serves two models, picked by the scenario the WebSocket is opened with:
| Model ID | Model | Scenario | Who can use it | Quota |
|---|---|---|---|---|
claude-opus (claude-opus-5.5, claude-opus-5) |
Claude Opus 5.5 | OfficeWebPaidCopilot |
paid/premium seat | small priority-access quota |
claude-opus-4.8 (claude-opus-4.5) |
Claude Opus 4.8 | OfficeWebIncludedCopilot |
premium account | none beyond the usual throttling |
The proxy picks the scenario from the model ID, so nothing has to be configured. This is an
entitlement, not a bypass: without a paid/premium seat neither model serves. claude-opus
then gets a licence-refusal message, and claude-opus-4.8 a 502 saying it's premium-only. licenseType
alone unlocks nothing; override either with M365_SCENARIO / M365_LICENSE_TYPE.
claude-opus-4.8's tool requests carry the Copilot Studio tool agent, like claude-opus's.
Without the agent Opus 4.8 has a code interpreter of its own, and unless the framing steers it away it
works there instead of in your harness, reporting success on files it never touched. The default
relay_batch framing does steer it away, and there the two paths solve the same, so the agent is the
safety margin. Requests without tools go without it. Until October 2026 this route served Claude Opus
4.5, and only through the agent; claude-opus-4.5 now reaches Opus 4.8 (see hypotheses §24, §36).
claude-opus is metered separately: 40 turns a day, 75 a week. Opus 5.5 draws on a "priority
access" budget distinct from the ~600-message conversation cap, and every M365 turn costs one,
whatever its size. In an agent loop every tool call is a turn, so one coding task costs 3–5 and a
day's budget is gone after about ten tasks. That's what issue #18
ran into; a shorter prompt doesn't help. Both budgets reset at midnight UTC, the weekly one on Monday.
What the proxy does about it:
- Shows the budget: every
claude-opusresponse carriesusage.x_m365_opus_daily_remainingandusage.x_m365_opus_weekly_remaining. - Says when it's used up: M365 ends the turn with a refusal ("You've used your available priority
access to the Opus model for today…"), which the proxy turns into HTTP 429
(
code: priority_access_exhausted,param: day|week) with aRetry-Afterto the reset — on the streaming path anerrorchunk with the same fields. It then answers furtherclaude-opusrequests itself until the reset, without spending M365 turns on more refusals. - Can fall back to Opus 4.8: with
M365_OPUS_FALLBACK_MODEL=claude-opus-4.8, aclaude-opusrequest that hits the wall is sent toclaude-opus-4.8instead (unmetered, premium accounts only), and so is every later one until the reset. The response'smodelfield says which model answered. - Spends fewer turns: the
relay_batchframing (below) asks the model to do as much as it can per tool call, and avoids the jailbreak-filter retries that costclaude-opus's old default framing a wasted turn on about 40% of tasks.
Both use the tool agent and the relay_batch framing. On the bench Opus 4.5 solved nearly every
task whatever the framing, so two other things decided it. M365's jailbreak classifier: the proxy's
older <system>-tagged framings tripped it on a third of the tasks (each one a wasted turn and a
retry in a fresh conversation), the user-voice relay framings on none. And turns:
relay_batch asks the model to put as much as it can into each command block, which took Opus 4.5
from 3.65 to 2.30 turns per bench task (20/20 either way) and from 6 to 3.5 turns per real-pi run
(10/10 either way) — on Opus 5.5 that's ~17 tasks a day instead of ~11. The cost to watch is a
block that acts before it has seen any output; in pi every edit still came after a read. Opus 5.5
went 10/10 on the bench with relay; it hasn't been benched with relay_batch yet. Opus 4.8, which
replaced Opus 4.5, went 30/30 with it at 2.5 turns per task.
Override with M365_FRAMING_VARIANT as usual. Details: hypotheses §24, §36.
Image generation
M365 Copilot generates images through a built-in server-side tool, and the core
package exposes it as one call. The picture comes back on a GraphicArt frame as
a URL (never as chat text), and the bytes sit behind a separate auth boundary —
generateImage() handles both, returning the image with bytes attached:
import { generateImage } from "@m365-copilot/core";
const [img] = await generateImage("A minimalist flat-design logo of a lighthouse, teal and white.");
// img.data -> Buffer (real PNG, verified end-to-end)
// img.base64 -> same bytes, ready for an OpenAI-style b64_json response
// img.contentType, img.size, img.orientation
Everything the M365 web client can do is reachable — the proxy sends the same image optionsSets it does. Steer type and aspect with options (they nudge the prompt the way the GUI's meta-prompting does; the model still makes the final call):
await generateImage("a lighthouse on a cliff", { orientation: "portrait" }); // landscape | portrait | square
await generateImage("a lighthouse", { style: "icon" }); // natural | icon | story | designer
You don't have to call generateImage at all. Just like the web client, a plain
chat turn draws when asked — send "draw me an image of a green teapot" to
/v1/chat/completions
…
Releases