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.
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.
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
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
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.
@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
- Node.js 24+
- pnpm 10+
- An M365 account with Copilot access
- TOTP-based MFA (the automated login needs the base32 secret)
git clone https://github.com/cramt/m365-copilot-proxy
cd m365-copilot-proxy
pnpm install
pnpm buildCreate ~/.config/opencode-m365/secrets.json:
{
"email": "you@company.com",
"password": "your-password",
"mfaSecret": "YOUR_TOTP_BASE32_SECRET"
}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.
Start the proxy:
m365-proxy 4143 # or: pnpm run proxy 4143Point pi at it via ~/.pi/agent/models.json:
{
"providers": {
"m365": {
"baseUrl": "http://localhost:4143/v1",
"api": "openai-completions",
"apiKey": "m365",
"compat": {
"supportsDeveloperRole": false,
"supportsReasoningEffort": false,
"supportsUsageInStreaming": false
},
"models": [{ "id": "m365-copilot", "name": "M365 Copilot" }]
}
}
}Then run pi (keep the toolset lean — M365 "disengages" on very large tool payloads; see docs/m365-copilot-api.md):
pi --models "m365*" -p --tools read,list,edit,write "your task"This is verified working end-to-end, including multi-tool calls and real file edits.
# Configure and start in one command
m365-openclaw-setup --start
# Or configure only, then start separately
m365-openclaw-setup
m365-proxy 4141The 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.
npx m365-proxy 4141
# or
pnpm run devThen point any OpenAI-compatible client at http://localhost:4141/v1.
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.
| Model ID | M365 Tone | Description |
|---|---|---|
m365-copilot / auto |
magic | Default auto-routing |
quick |
Gpt_Quick | Fast responses |
think-deeper |
Gpt_Reasoning | Slower, more thorough |
gpt-5.4 |
Gpt_5_4_Reasoning | GPT-5.4 reasoning |
gpt-5.4-quick |
Gpt_5_4_Quick | GPT-5.4 fast |
gpt-5.3 |
Gpt_5_3_Quick | GPT-5.3 fast |
gpt-5.3-think-deeper |
Gpt_5_3_Reasoning | GPT-5.3 reasoning |
gpt-5.2 |
Gpt_5_2_Quick | GPT-5.2 fast |
gpt-5.2-think-deeper |
Gpt_5_2_Reasoning | GPT-5.2 reasoning |
⚠️ For tool calling, use the default or a*-quicktone. The reasoning tones (*-think-deeper, baregpt-5.x) route through M365'sDeepLeopipeline, which meta-analyzes the injected prompt instead of obeying it and disengages from tools. See docs/m365-copilot-api.md §5/§10.
The auth flow uses Azure MSAL with PKCE:
- Silent refresh — Uses cached tokens from
~/.config/opencode-m365/msal-cache.json - Automated login — Playwright-driven browser login using stored credentials + TOTP
- Interactive login — Opens browser for manual OAuth flow (fallback)
Three token scopes are acquired:
substrate.office.com/sydney/*— For M365 Copilot chatapi.powerplatform.com/.default— For Copilot Studio agent managementapi.bap.microsoft.com/.default— For environment discovery
| Variable | Description |
|---|---|
M365_DEBUG |
Set to 1 to enable debug logging to ~/.config/opencode-m365/debug.log (truncated payloads) |
M365_TRACE |
Set to 1 for full, untruncated debug logging (every WS frame/prompt/response) — implies M365_DEBUG. For reverse engineering. |
M365_DUMP_FRAMES |
Set to 1 to write every WebSocket frame (both directions) to ~/.config/opencode-m365/frames/<requestId>.ndjson. For offline diffing of new M365 fields. |
M365_ALLOW_MULTI_TOOL |
Allow the model to emit multiple tool calls per turn (default: only the first is kept) |
M365_INJECT_REPLY_TOOL |
Set to 1 to inject a synthetic reply(text) tool. Forces every turn to be a tool call, including pure-prose answers. Cleaner contract for the model, +1 tool to the prompt (watch the Disengaged threshold). Confirmed 5/5 compliance on June 9 2026 (hypotheses §1.1). |
M365_NO_CONFAB_RETRY / M365_CONFAB_RETRIES |
M365's chat model sometimes produces prose instead of a tool call when it should act — either confabulating an inability ("I can't access the files, please paste them") or claiming a completion it never did ("I've replaced the README", with no tool call). By default the proxy detects both and re-prompts forcefully in the same conversation (M365_CONFAB_RETRIES, default 1) to force a real action. Set M365_NO_CONFAB_RETRY=1 to disable. |
M365_NO_BACKOFF (alias M365_NO_AUTO_REAUTH) |
Set to 1 to disable degradation backoff. By default, when empty/throttled responses span several distinct conversations in a short window (the thread-rate-throttle signature, F13), the proxy paces subsequent turns (a jittered delay before starting new backend conversations) to let the account self-heal. This replaced the old auto-reauth: a fresh login does not clear this throttle (it's oid-keyed — §11 H-R1) and raised our detection profile. A single long pi thread never trips the trigger. |
M365_BACKOFF_THRESHOLD / M365_BACKOFF_WINDOW_MS / M365_BACKOFF_BASE_MS / M365_BACKOFF_MAX_MS |
Tune backoff: distinct-conversation empties to trigger (default 3), the window they must fall in (default 120000), the initial pacing window (default 90000), and its escalation cap (default 600000). |
M365_BROWSER_PROFILE / M365_LOGIN_UA |
Override the persistent browser-profile dir and the login User-Agent used for the (rare) automated interactive login. The persistent profile keeps AAD SSO/device cookies so repeat logins are silent and look like a familiar device (§11 H-R3). |
M365_CACHE_FILE |
Override MSAL token cache location |
M365_SECRETS_FILE |
Override credentials file location |
CHROMIUM_PATH |
Path to Chromium binary for automated login |
The OpenAI usage block in every chat completion response now includes M365
extension fields with the per-conversation message quota — the closest
proxy we have to "context-window utilisation" since M365 hides token counts:
"usage": {
"prompt_tokens": 0,
"completion_tokens": 0,
"total_tokens": 0,
"x_m365_conversation_messages": 42,
"x_m365_conversation_max": 600,
"x_m365_conversation_pct": 7,
"x_m365_conversation_remaining": 558,
"x_m365_content_origin": "DeepLeo",
"x_m365_message_type": null,
"x_m365_turn_count": 3,
"x_m365_classifier_scores": {
"BotOffense": 1.27e-7,
"dea_violation": 2.81e-6
},
"x_m365_dea_score": 2.81e-6,
"x_m365_offense_score": 1.27e-7
}x_m365_dea_score is M365's own "disengaged-eligible answer" classifier
score — the closest signal to "am I about to get Disengaged?". Empirically:
clean tool calls sit at ~1 × 10⁻⁸, prose at ~1 × 10⁻⁶, jailbreak-shaped
prompts at ~1 × 10⁻³. Disengaged itself fires at some threshold > 2 × 10⁻³
that we haven't yet pinpointed. Clients can monitor this to back off before
tripping the filter.
Clients that ignore unknown extension fields keep working; curious users can read them. See docs/hypotheses.md §0 for the full findings dump and §2 for what we tried and didn't find.
All stored in ~/.config/opencode-m365/:
| File | Description |
|---|---|
secrets.json |
Login credentials (email, password, mfaSecret) |
msal-cache.json |
MSAL token cache (auto-managed) |
agent-id.json |
Cached Copilot Studio agent ID |
debug.log |
Debug log (when M365_DEBUG=1) |
pnpm install
pnpm build # Build all packages
pnpm run dev # Start standalone proxy on :4141
pnpm run test:unit # Run vitest unit tests (no auth/network)
pnpm run test:live # Run live integration tests against M365- M365 "disengages" on large tool payloads — heavy agent harnesses (e.g. opencode's ~15-tool prompt) get empty
Disengagedresponses. Keep the toolset lean (this is why pi works well). See docs/m365-copilot-api.md. - Tool calling is emulated (prompt injection + a Copilot Studio agent), not native function calling — robust with the agent, unreliable without it
- The
think-deeper/*_Reasoningmodels take 10-30s per response - Hard quota of ~600 messages per conversation (mitigated by session reuse + delta sends)
- Streaming: tool-less responses stream incrementally (deltas forwarded as they arrive). Tool-calling turns are still buffered server-side — the raw text has to be parsed for tool-call fences before it can be emitted — so those arrive as a single chunk at the end (with an immediate HTTP 200 + heartbeats so the client never times out waiting)