Turn Microsoft 365 Copilot into a full agentic coding model for OpenCode.
OpenCode talks OpenAI to http://localhost:8080/v1. The proxy signs you in
with device code, drives the Graph Copilot Chat API, and — the important part —
gives Copilot tool calling it doesn't natively have:
OpenCode --- OpenAI JSON + tools[] ---> proxy --- prompt + protocol ---> M365 Copilot
OpenCode <--- OpenAI tool_calls ------- proxy <--- text w/ tool_call blocks --- Copilot
OpenCode runs read/grep/glob/edit/bash ON YOUR REPO, sends results back, repeat.
Copilot never touches your files; OpenCode's own local tools do. Copilot is just the brain. That's how it "understands the repo" — the same way every agent does: by reading it.
258 MCP tools offered per turn drowns any model. opencode-plugin/mcp-autopilot.ts
is an OpenCode plugin (installed to ~/.config/opencode/plugins/ by the
installer/launcher) that, before each of your messages, connects the MCP
servers your text calls for — server-name tokens, your regex rules, an
always list — and disconnects servers idle for N turns. The model also
gets mcp_list / mcp_enable / mcp_disable to pull a server in itself
mid-task. Works with Opus or anything else; nothing to do with the proxy.
Config: ~/.config/opencode/mcp-autopilot.json (see the example file).
Copilot grounds on Teams/mail/SharePoint whenever a prompt sounds like
history ("continue where we left off") and decorates answers with [1]
citations and a "Sources:" list. The Graph API has no switch for that, so:
persona + protocol forbid it and define "where we left off" as git status,
git log, AGENTS.md/TODO/NOTES, todoread — tools first, then act; replies
that lean on "records", "Teams", "enterprise sources" get the tool nudge;
and all attributions/citations are stripped from replies (SOURCES=on to
keep them). Tool-call blocks and code indices like arr[0] are untouched.
Copilot treats Graph contexts[] as reference documents — it read OpenCode's
skills list from there but never "saw" the tool protocol sent the same way.
So persona, thinking format, system prompt, repo map and the tool catalog are
placed inside the message text (fresh conversation: full preamble; reused
conversation: names-only reminder). INSTRUCTIONS_IN=contexts restores the
old behaviour if you want to compare. /stats → last_turn shows, per turn,
tools offered / tool calls / nudged / prompt bytes / a redacted reply head.
All of that is OpenCode's side of the wire. MCP servers add tools to
tools[]; skills arrive as system-prompt text or a skill tool; bash is
just another tool. The proxy offers Copilot whatever OpenCode sends, and
OpenCode executes what Copilot picks — on your machine, under OpenCode's own
permission rules. Nothing to configure here. To keep turns small with many
MCP tools attached, the full catalog is sent once per Graph conversation and
later turns carry a names-only reminder.
- Tool-calling shim — strict fenced-JSON protocol injected as a Copilot
context; parsed back into real OpenAI
tool_calls(parallel calls, lenient XML/wrapped forms, unknown tool names dropped, ordinary code fences untouched). - Conversation reuse — the Graph conversation is kept per message-prefix; each agent step sends only the new tool results, not the whole transcript.
- Streaming — proper SSE incl.
tool_callsdeltas andfinish_reason. - 429/5xx backoff with
Retry-After. - Capability probe — on startup, scans Graph
$metadatafor anything model/reasoning-shaped and logs it (see "Model selection"). - Repo map — branch, language mix, tree (depth 3), README head, injected
as context so Copilot is oriented before its first tool call. Root from
REPO_ROOTor parsed from the client's system prompt.REPO_MAP=offto disable. - Protocol enforcement — if tools were offered and Copilot answers "I
can't access your files…" or narrates instead of acting, the proxy re-prompts
once on the same conversation.
ENFORCE_TOOLS=offto disable. - Argument repair + validation — trailing commas, single quotes, raw newlines, prose-wrapped JSON all get fixed; missing/unknown arguments (per the tool's schema) trigger one precise re-prompt ("missing required argument "path"") instead of a broken call reaching OpenCode.
- Size control — tool results are head+tail compacted (
MAX_TOOL_RESULT, default 24k); replayed histories fold old tool results to one-liners; if Graph still says "too large" the proxy halves the budget and retries. - Per-conversation lock — OpenCode's parallel requests (title gen + main turn) can't interleave writes to one Graph conversation.
/stats— turns, tool calls, nudges, repairs, conv reuse, shrinks, latency p50/p95. Curl it while tuning against the real model.- Debug dump —
DEBUG_DIR=/tmp/copilot-dbgwrites one JSON per turn: exact contexts + prompt Copilot saw, raw reply, whether a nudge fired. Redacted by default (emails, GUIDs, tokens, home dirs, tenant hosts, IPs);DEBUG_RAW=1keeps raw dumps for local use only.scrub.shapplies the same rules to any evidence file before it leaves the machine. - Always-on coding-agent persona — Copilot shows up as a terse senior
engineer that ignores your mailbox, not an Office add-in. Override with
AGENT_PERSONA="...", disable withAGENT_PERSONA=off. - Confidential-client support (secret on refresh) and public-client device code.
The Graph Copilot Chat API exposes no model parameter and no model list.
Microsoft picks the model server-side ("Think Deeper"/GPT-5 are UI features,
not API knobs). There is nothing to query, so this proxy doesn't pretend to.
What it does: the startup probe logs POSSIBLE MODEL KNOBS if Microsoft ever
adds one to the schema; then set GRAPH_EXTRA_BODY='{"model":"..."}' and every
chat body carries it. No code change.
curl -fsSL https://raw.githubusercontent.com/zyads/m365-copilot-proxy/main/install.sh | bash
opencode-copilot # every time: ensures proxy is up + current, signs in once, launches OpenCodeinstall.sh builds, asks for tenant/client id once (stored 0600 in
~/.config/m365-copilot-proxy/env), installs a user service (systemd
with linger on Linux, launchd on macOS — survives logout, starts at boot;
nohup/setsid fallback elsewhere) and two commands:
opencode-copilot [args]— start-if-down →POST /update(pull, rebuild, re-exec when idle if main moved) → show device code if not signed in → merge them365provider (with the configured port —LISTENin the env file, orPROXY_PORT) into your global~/.config/opencode/opencode.json→exec opencode. Merging, not replacing: your MCP servers, skills and project configs stay untouched (a one-time.pre-m365.bakis kept). The repo'sopencode.jsonis just the template.copilot-proxy status|start|restart|stop|logs|update|auth.
The proxy also checks origin/main itself on startup and every 6h
(UPDATE_INTERVAL_MIN, AUTO_UPDATE=off). A failed build rolls the checkout
back; the old binary keeps running.
OpenCode shows a collapsible thinking block for reasoning_content deltas.
Copilot has no reasoning tokens, so the proxy asks it to think inside
<thinking>…</thinking> first, splits that out, and streams it as
reasoning — the answer/tool calls stay clean. Planning before acting also
measurably improves protocol adherence. THINKING=off to disable.
Delegated permissions, admin-consented — all of them, even for plain chat
(Graph's 403 names this exact list; see docs/field-test-2026-08-25-enterprise.md):
Sites.Read.All, Mail.Read, People.Read.All, OnlineMeetingTranscript.Read.All,
Chat.Read, ChannelMessage.Read.All, ExternalItem.Read.All.
The proxy rewrites that 403 into a plain-English message listing them.
The signed-in user needs an M365 Copilot license. No application permissions.
Three ways to get a token — pick by what your tenant allows:
AUTH_MODE |
App registration needs | When |
|---|---|---|
device_code (default) |
Authentication → Advanced → Allow public client flows: Yes | simplest; often blocked by Conditional Access ("sign-in was successful but does not meet the criteria") |
browser |
Redirect URI http://localhost:8080/auth/callback (platform Mobile and desktop, or Web for confidential apps) |
CAP-friendly — the same auth-code+PKCE flow every desktop app uses. copilot-proxy login opens it; the launcher does too. |
any + copilot-proxy seed-token |
nothing | you already hold a delegated refresh token for this app from another flow; paste it once, the proxy refreshes from it forever |
Confidential app (has a secret)? Set M365_CLIENT_SECRET; it's sent on every
token call including refreshes.
export M365_TENANT_ID=<tenant-guid>
export M365_CLIENT_ID=<app-client-id>
./run.sh # prints a microsoft.com/devicelogin URL + code on first runRefresh token is cached at ~/.config/m365-copilot-proxy/token.json (0600).
curl -s localhost:8080/v1/chat/completions -H 'content-type: application/json' \
-d '{"model":"m365-copilot","messages":[{"role":"user","content":"Summarise my last 3 emails"}]}'Copy opencode.json into the project you run OpenCode in (or
~/.config/opencode/opencode.json). Pick model m365/m365-copilot. No fork.
patch_opencode.sh rewrites hardcoded upstream URLs in a clone if you insist
on a hard fork.
- Tool calling is prompt-driven. A GPT-5-class Copilot follows the protocol reliably; if a reply comes back with prose instead of a block, OpenCode just shows the prose and you nudge it. Unknown tool names are dropped.
- Graph reports no token usage;
usageis estimated (len/4). - The Copilot Chat API is
/beta; paths are env-overridable if Microsoft moves it. - Copilot may refuse or add enterprise "Sources:" — sources are only appended to final prose answers, never to tool-call turns.
- Tenant Conditional Access can block device code; use
M365_CLIENT_SECRETwith a confidential app registration in that case (refresh still works).
go test ./... # mocked Graph: plain chat, full tool loop w/ conversation reuse, SSE tool deltas, lenient parsing
go build -o m365-copilot-proxy .