Skip to content

Repository files navigation

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:

  1. Standalone proxy — HTTP server with /v1/chat/completions and /v1/models endpoints. Works with any OpenAI-compatible client (pi, OpenClaw, etc.).
  2. 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: value header lines, one free-form body arg as the fence body, old/new edits as aider-style SEARCH/REPLACE diffs)
  • The proxy/handler parses that and converts it to OpenAI tool_calls format
  • Shell-routing (the key lever): M365's chat-tuned model won't "act as an agent" on demand but will reflexively write a ```bash block. When the toolset includes a shell tool (bash/shell/run/run_command/… — any name), the proxy injects "do the whole step by writing one ```bash block" 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:

  1. Discovers the environment URL via the BAP API (api.bap.microsoft.com)
  2. Creates a bot with instructions in the Copilot Studio minimalBots API
  3. Publishes the bot to get a TitleId
  4. Uses the agent ID (T_{titleId}.{botId}.gpt.default) in WebSocket chat requests
  5. 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+
  • An M365 account with Copilot access
  • 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=1 and 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, AZ and 27 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:

  1. Go to https://aka.ms/mfasetup (My Account → Security info).
  2. Add sign-in method → Authenticator app.
  3. 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.
  4. At the QR-code screen, click "Can't scan image?". It reveals a Secret key — that's your base32 string.
  5. 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=1 to 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/device with a short code — is permanently dead here, not merely unimplemented. Initiation returns a valid code, but redeeming it fails with AADSTS7000218: Entra treats Microsoft's own Copilot client as confidential for that grant and demands a client_secret only 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": "m365",
      "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)" }
      ]
    }
  }
}

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.

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.

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-5.6-think-deeper Gpt_5_6_Reasoning GPT-5.6 reasoning — live-validated; agent/tool reliability not yet benchmarked
gpt-5.5-think-deeper Gpt_5_5_Reasoning Recommended default for agents/tool-calling — robust tool compliance
gpt-5.5 / gpt-5.5-quick Gpt_5_5_Chat GPT-5.5 fast
m365-copilot / auto magic Auto-routing — high-variance at tool-calling (confabulates; see below)
quick Gpt_Quick Fast responses
think-deeper Gpt_Reasoning Slower, more thorough
claude / claude-sonnet Claude_Sonnet Real Anthropic Claude (agent-less path)
claude-sonnet-think-deeper Claude_Sonnet_Reasoning Claude reasoning
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

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 — 100% compliance and solve across prompt/toolset sizes on the bench (docs/hypotheses.md §12.10/§12.11). The default m365-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 no model field already defaults to gpt-5.5-think-deeper for this reason.

⚠️ The older reasoning tones (gpt-5.2/gpt-5.3/gpt-5.4 *-think-deeper, bare think-deeper) route through M365's DeepLeo pipeline, which meta-analyzes the injected prompt and can disengage from tools. Prefer gpt-5.5-think-deeper. See docs/m365-copilot-api.md §5/§10.

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 (or ModelSession.run) with no tools and the image comes back embedded in the reply as a markdown data-URI. (Image gen is enabled on the agent-less path only, so it never competes with tool calling; set M365_NO_IMAGE_GEN=1 to force pure text.)

Runs its own agent-less session. Uses the same login as chat; the artifact fetch uses a designerappservice token acquired silently from the existing cache. Protocol write-up: docs/hypotheses.md §14.

Separate, scarcer budget. Image generation draws on its own daily quota, distinct from the ~600-message conversation limit and not metered by the chat throttle — treat image calls as the expensive ones. When it's exhausted, generateImage() throws ImageGenerationError with reason: "quota_exceeded" (map it to HTTP 429); a plain chat turn instead returns M365's "can't generate any more images today" message as text.

An OpenAI-compatible POST /v1/images/generations endpoint on top of this is the next step — the core API it needs is already in place.

Authentication

The auth flow uses Azure MSAL with PKCE:

  1. Silent refresh — cached tokens from ~/.config/opencode-m365/msal-cache.json. The normal path; costs nothing and opens nothing.
  2. Automated login — headless Playwright browser driving the AAD form with stored credentials + a TOTP code generated from mfaSecret.
  3. Interactive approval — visible browser, human completes SSO/MFA (§13). Only when M365_ENABLE_INTERACTIVE_APPROVAL=1, and only after step 2 is unavailable or has actually failed. This is the path for tenants where no TOTP seed exists.

All three redeem the code against https://login.microsoftonline.com/common/oauth2/nativeclient with PKCE. That redirect isn't a stylistic choice: the client is Microsoft's own Copilot app (the Sydney scopes are granted to no other), so nobody can register a loopback URI — a generated http://localhost:<port> callback is rejected with AADSTS50011, and the device-code grant demands a client_secret only Microsoft holds (AADSTS7000218). Both measured live in §13; don't spend probes re-deriving them.

Three token scopes are acquired:

  • substrate.office.com/sydney/* — For M365 Copilot chat
  • api.powerplatform.com/.default — For Copilot Studio agent management
  • api.bap.microsoft.com/.default — For environment discovery

Environment variables

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_LOG_STDOUT Set to 1 to mirror debug lines to the proxy's stdout as well as the log file, so you can watch a run without tailing it in a second terminal. Needs M365_DEBUG or M365_TRACE — on its own it logs nothing.
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_ENABLE_INTERACTIVE_APPROVAL Set to 1 to allow a visible browser window for sign-in when the automated login can't work or fails — the fallback for tenants with no TOTP option (push-only MFA, FIDO2, Okta/Ping/Duo). You complete SSO/MFA by hand once; tokens refresh silently afterwards. Off by default so headless hosts fail loudly rather than hang. See If your tenant has no TOTP option.
M365_NO_INTERACTIVE Set to 1 to hard-disable any visible browser login, overriding the flag above. For systemd/CI hosts where a window must never open.
M365_INTERACTIVE_TIMEOUT_MS How long to wait for you to finish the interactive sign-in (default 600000, i.e. 10 minutes).
M365_LOGIN_LOCALE / M365_LOGIN_TIMEZONE Browser locale and timezone presented during login (defaults en-GB / Europe/Copenhagen). These are part of the anti-bot-scoring fingerprint (§11 F25) — set them to match your own machine if AAD starts treating your automated login as a bot.
M365_CACHE_FILE Override MSAL token cache location
M365_SECRETS_FILE Override credentials file location
CHROMIUM_PATH Path to Chromium binary for automated login

Usage / context-window % in responses

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.

Config files

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)

Development

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

Known limitations

  • M365 "disengages" on large tool payloads — heavy agent harnesses (e.g. opencode's ~15-tool prompt) get empty Disengaged responses. 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 / *_Reasoning models 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)

License

MIT. Use at your own risk — this speaks to Microsoft's API with your own credentials, on your own account, and that's between you and your tenant's acceptable-use policy.

About

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.

Topics

Resources

Stars

52 stars

Watchers

1 watching

Forks

Releases

Packages

Contributors

Languages