Skip to content

cramt/m365-copilot-proxy

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
  • TOTP-based MFA (the automated login needs the base32 secret)

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"
}

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": "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.

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
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 *-quick tone. The reasoning tones (*-think-deeper, bare gpt-5.x) route through M365's DeepLeo pipeline, which meta-analyzes the injected prompt instead of obeying it and disengages from tools. See docs/m365-copilot-api.md §5/§10.

Authentication

The auth flow uses Azure MSAL with PKCE:

  1. Silent refresh — Uses cached tokens from ~/.config/opencode-m365/msal-cache.json
  2. Automated login — Playwright-driven browser login using stored credentials + TOTP
  3. Interactive login — Opens browser for manual OAuth flow (fallback)

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_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

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)

About

No description, website, or topics provided.

Resources

Stars

17 stars

Watchers

1 watching

Forks

Packages

 
 
 

Contributors