Use your organisation's Microsoft 365 Copilot as the model backend for opencode.
M365 Copilot has no developer API. It is an undocumented SignalR-over-WebSocket service
(substrate.office.com) that Microsoft intends only its own clients to speak to. This
package implements that protocol, wraps it in an OpenAI-compatible proxy, and ships an
opencode plugin that starts the proxy in-process and registers it as a provider.
opencode ──OpenAI /v1──▶ in-process proxy ──SignalR/WS──▶ M365 Copilot
│
└── local-title model, answered without any M365 call
npm install -g opencode-m365-copilot
opencode-m365 login # one-time browser sign-in
opencode-m365 setup # adds the plugin to ~/.config/opencode/opencode.json
opencode # models appear as m365/*setup writes only a plugin reference. The provider, the model list and the default
model are all registered by the plugin at startup.
Needs opencode 2 — from 2.0.0 the major version tracks it. On opencode 1, stay on 0.4.x — see opencode 1.
M365 refuses to work with a full coding-agent toolset. Its "Disengaged" filter
returns an empty reply — indistinguishable from rate limiting unless you look for
messageType: "Disengaged" — and the reference reverse-engineering notes measure the
threshold at roughly 12 tools, naming opencode specifically as the harness that
disengages persistently.
So this plugin's main job is not wiring. It is cutting opencode down to something M365 will engage with. Measured against opencode 1.18.18 on a single agentic turn:
| stock | with this plugin | |
|---|---|---|
| tools offered | 9 | 4 |
| prompt sent to M365 | 53,676 chars | 9,701 chars |
<available_skills> catalogue |
34,263 chars | removed |
The trimming is on by default. Turn it off with { "lean": false } and expect empty
replies.
Not in opencode's config — in the proxy. Two opencode levers look like they should work and do not (verified against 1.18.18, and the reason the architecture is what it is):
config.toolsis resolved into the config —opencode debug configshows it correctly — but the request still offers every tool. A config disabling eight tools produced a request offering all nine.experimental.chat.system.transformfires, and accepts a replacementsystemarray, but the request that reaches the provider still carries the original prompt. Settingagent.<name>.promptfrom theconfighook is ignored the same way.
The proxy sees the final request, so it is the one place the trim reliably lands.
What gets dropped:
- Tools — keeps
bash,read,grep, and whichever editing tool is present (apply_patchin 1.18.18,edit/writein others). Dropsglob,skill,task,todowrite,webfetch,websearch,lsp,question. - Capability catalogues —
<available_skills>is removed when noskilltool survives the trim, because it advertises capabilities nothing can invoke.<env>and other structured context are kept. - The harness's prose system prompt — replaced with a short one, but off by
default. A long, polished assistant prompt measurably reduces M365's tool
compliance on another harness's numbers; that is a borrowed result we have not
reproduced, so it is opt-in via
{ "leanSystemPrompt": true }. Your own rules are never dropped either way: opencode injectsAGENTS.mdand global rules as prose inside the system message, and those sections are preserved verbatim.
bash is never dropped. M365's chat-tuned model will not "act as an agent" on request,
but it will reflexively write a ```bash block — routing that block to the
shell tool is the single lever that makes tool calling work at all.
M365 has no native tool_calls. Tools go out as a fenced contract and come back as
fenced blocks:
```read
filePath: /etc/hostname
```
Scalar arguments are key: value header lines, one free-form argument is the fence body,
and an old/new pair is written as a SEARCH/REPLACE diff. A JSON {"tool": …} contract
was tried in the reference project and scored 0/5 on real agentic tasks.
The other half is a Copilot Studio declarative agent, provisioned automatically on the first turn that carries tools. Microsoft's server-side prompt outranks anything we send, so per-request instructions get answered in prose; instructions delivered through an agent land in the server-side system prompt and are obeyed. The agent is named after a hash of its instructions, so hosts on a tenant converge on one, and it is never deleted — another host may be mid-conversation with it.
Without Copilot Studio access the plugin still works, just less reliably; it logs a warning and carries on.
This is an opencode 2 plugin (@opencode/cli, @opencode/plugin): its default export
is { id, setup }. A local checkout is referenced by its directory — opencode 2
drops a file path with configured plugin path must be a directory, and since it
swallows plugin load failures, nothing else tells you. opencode-m365 setup writes the
right reference for you.
Everything in Where the trimming happens still
lives in the proxy. opencode 2 does offer hooks where opencode 1's were dead
(ctx.tool.transform, session.hook("context")); neither has been measured against a
live tenant, so the proxy stays the enforcement point.
opencode 1 is no longer supported. 0.4.x is the last release that loads there — pin
it with npm install -g opencode-m365-copilot@0.4. 2.0.0 and later under opencode 1
fail to load with must default export an object with server(). Re-running
opencode-m365 setup removes this plugin's entry from opencode 1's plugin key, keeping
any options you set on it, and leaves other plugins' entries alone.
Against a real opencode 2.2.5, end to end, with the proxy running against the offline
stub rather than Microsoft: the plugin loads from a directory, registers all 21 models,
an attached image is uploaded and attached to the turn, and the session title is
answered locally — one chat turn reaches the backend, not two. The catalog records are
also checked against the real @opencode/plugin schema constructors in the test suite.
Nothing here has been run against a live M365 tenant.
The in-process proxy needs no key configured: it mints a random secret each launch and
hands it to opencode itself. Only a standalone opencode-m365 serve proxy needs one —
serve prints its key, or takes a fixed one from M365_PROXY_KEY. Either put that key
in apiKey, or export the same M365_PROXY_KEY to opencode and leave apiKey out.
opencode titles every new session with a model call on your main model, which on M365 would open a second conversation per session. The account-level throttle counts conversations started, not messages, so that is the fastest way to get throttled.
opencode 2 has no small_model setting, and its title hook exposes the model read-only,
so the plugin marks the title request on a header (x-m365-request-kind: title) and the
proxy answers it locally, without opening a connection. Compaction and generation stay
on the real model — they are real work, and only titling opens the extra conversation.
setSmallModel turns this off; the name dates from opencode 1, where it set
small_model. Any OpenAI client can also select m365/local-title by id.
Models are selected by an M365 tone, not a model id. m365/gpt-5.5-think-deeper is the
default.
A conflict worth knowing about. The reference project's README recommends
gpt-5.5-think-deeperand reports 100% tool compliance with the agent plus fenced/shell-routing. Its own protocol notes (m365-copilot-api.md, quirk #13) say the opposite — that reasoning tones meta-analyse the injected prompt and disengage, and onlymagic/*_Quickwork with an agent attached. The README is the newer measurement, so it is the default here; if you see disengagement on tool turns, trym365/quickorm365/m365-copilot.
A non-default tone (Claude, the reasoning tones) only takes effect when no agent is attached — with one, M365 silently routes to GPT regardless. The proxy therefore attaches the agent only on turns that carry tools, so plain chat reaches the model the tone selects.
Sign-in is a CLI concern, never the plugin's: the plugin only refreshes silently and asks
you to run opencode-m365 login if it cannot. A plugin running underneath a TUI has no
business opening a browser.
Two ways to sign in:
- Automated (headless) — put
{ "email", "password", "mfaSecret" }in~/.config/opencode-copilot/secrets.json.mfaSecretis the base32 seed your authenticator derives codes from (JBSWY3DPEHPK3PXP), not a 6-digit code. Most password managers will show it; anotpauth://URI is accepted and the seed extracted. That file holds both sign-in factors — anyone who can read it can pass MFA as you — so keep itchmod 600(the CLI tightens a looser file itself and warns), keep it off backups and synced folders, and prefer interactive sign-in if you can. It is only read byopencode-m365 login; you can delete it once you have signed in. - Interactive —
opencode-m365 login --interactiveopens a window and you complete SSO/MFA by hand once. Required for tenants with push-only MFA, FIDO2, or a federated IdP (Okta/Ping/Duo), where no seed exists to extract.
Either way it is one-time; afterwards tokens refresh from the MSAL cache.
The client id is Microsoft's own Office web Copilot application. That is not a shortcut —
the Sydney scopes are granted to no other client, so a loopback redirect is rejected
(AADSTS50011) and the device-code grant demands a client secret only Microsoft holds
(AADSTS7000218). Driving a browser is the only door.
State lives in ~/.config/opencode-copilot/ — deliberately not ~/.config/opencode-m365/,
which belongs to the m365-copilot-proxy project.
opencode-m365 login [--interactive] # sign in
opencode-m365 setup [--local] # register the plugin with opencode
opencode-m365 serve [--port 4141] # run the proxy standalone, for any OpenAI client
# (send its key as `Authorization: Bearer`)
opencode-m365 doctor # check auth, agent, proxy and opencode wiringPaste an image into opencode (or attach one with opencode run -f shot.png) and the
model sees it. opencode sends it to the proxy as an image_url data URL; the proxy
uploads it to M365 the way the Copilot web client does — POST /m365Copilot/UploadFile
into the same conversation — and the turn points at the upload with an ImageFile
annotation. Only images new to a turn are uploaded: M365 already holds the earlier ones.
- If an upload fails, the turn still runs and the model is told the image did not arrive, so it says so instead of describing a picture it never saw.
- Data URLs only. A remote
https://image URL is not fetched. - The local titler model stays text-only; it never reaches M365.
- The M365 side is implemented from cramt/m365-copilot-proxy's measurement (one model, agent attached and not); like the rest of this README's M365 claims, it has not been run against a live tenant from here.
- 600 user messages per conversation. The proxy reuses one conversation per task,
sends only new messages, and reports the remaining budget in
usage.x_m365_conversation_remaining. A task is the opencode session on opencode 2, or the system prompt plus the first user message otherwise; turns within one task run one at a time. - Account-level throttling exists and is keyed to your identity, so re-authenticating does not clear it. It self-heals after a lull.
- Streaming works for tool-less turns. A tool turn is buffered, because a fenced call cannot be parsed until the fence closes.
- Tool calling is emulated, not native, and one call per turn is kept by default — M365 batches its whole plan into one response, and later steps run on guessed state.
usagereports zero tokens. M365 never exposes token counts; thex_m365_*extension fields carry what it does report, includingx_m365_dea_score, the classifier score that rises before the Disengaged filter fires.
npm ci
npm test # typechecks, then runs the suite; no network and no credentials required
npm run buildTests run against a stub SignalR server (test/stub-copilot.ts) that speaks the real
framing, so a full turn — including the mandatory Metrics frame, ping handling,
Disengaged detection and the stop-on-abort frame — is exercised offline.
No live M365 traffic has been run against this implementation. Everything above about
opencode's behaviour is measured; everything about M365's behaviour is implemented from
the protocol notes in
cramt/m365-copilot-proxy (MIT), whose
docs/ are the source of truth for this surface. Run opencode-m365 doctor against your
own tenant before trusting it.
The protocol this implements was reverse-engineered by cramt/m365-copilot-proxy. This is an independent implementation written against that project's documentation, not a fork of its code.
MIT. This speaks to Microsoft's API with your own credentials on your own account — whether that is acceptable is between you and your tenant's acceptable-use policy.
{ "plugins": [{ "package": "opencode-m365-copilot", "options": { "lean": true, // trim the toolset (default: true) "leanSystemPrompt": false, // replace opencode's prose prompt (default: false) "setDefaultModel": true, // set `model` if you have not (default: true) "setSmallModel": true, // answer session titles locally (default: true) "baseUrl": null, // use an already-running proxy instead of an in-process one "apiKey": null // that proxy's secret (or export M365_PROXY_KEY instead) } }] }