An Office Add-in that surfaces your own self-hosted LLM inside Word, Excel, PowerPoint, and Outlook — with persistent per-user conversation history scoped to the active document, Microsoft Graph integration, and admin-side token-usage analytics.
The backend is a small ASP.NET Core 10 service that proxies any OpenAI-compatible endpoint (Ollama, vLLM, LM Studio, a hosted gateway, your own model server). Auth is Microsoft Entra ID; persistence is Postgres; deployment is docker compose up.
- In-document AI assistant. Open Word / Excel / PowerPoint / Outlook → the task pane shows a chat UI wired to your LLM. The current document's contents are attached to every request as system context.
- Per-user, per-document history. Conversations are scoped to a stable document identity (OneDrive driveItem ID, a local SHA-256 fingerprint, or an Outlook conversation ID). Reopen
Q3-report.docxnext week and your past threads on that file are right there. - Opt-in thread sharing. A conversation owner can flip a thread from
privatetoshared; collaborators with live Graph read-access to the underlying file can then read and append. ACL is re-checked per request, not cached on the conversation. - Three apply modes.
Plan(read-only),Ask(preview every write),Edit(auto-apply safe inserts, still preview destructive ops). Risk is classified per-action against live document state, not statically. - Streaming responses via SSE — tokens arrive token-by-token, with a keep-alive comment to outlast Cloudflare's ~100s idle timeout.
- File attachments. PDFs and images go to the LLM as multimodal items; text/markdown is inlined.
- PowerPoint deep integration. The model can emit
```ppt-action:image / rewrite / layout / shape```blocks; the client parses them, generates images server-side, and applies them through PowerPoint.js. - Excel intelligence. The model can read live cell data via
```excel-query```blocks (read_range, aggregate, group_aggregate, unique — runs client-side against the live workbook). It can insert native charts via```excel-chart```blocks, and write structured tables via```doc-write```. Large tables that hit the model's output-token limit are salvaged from truncated responses; ragged rows are normalised to the header width rather than failing the whole write. - Word smart write-back. Every
doc-writeresponse is classified by write target at apply time: selected text → in-place replace; no selection + non-empty document → offers Replace entire document or Append at cursor; empty document → plain insert. All Word writes require explicit user approval (Apply / Skip button) regardless of mode — nothing auto-applies. - Admin analytics. Token usage per user / per model / per host, surfaced at
/admin.html. Stateless/chatcalls are still attributed (with null conversation/message IDs) so spend is never invisible. - Non-destructive compaction. When history approaches the LLM's context window, older messages are summarised into a
summarymessage and marked archived — never deleted.
┌────────────────────────────────────────────────────────────────┐
│ Office 365 host (Word / Excel / PowerPoint / Outlook) │
│ ┌──────────────────────────────────────────────────────────┐ │
│ │ Add-in task pane (wwwroot/ — HTML + vanilla JS) │ │
│ │ ─ Office.js → reads doc content + identity │ │
│ │ ─ MSAL.js → Entra access_as_user token │ │
│ │ ─ SSE → /chat or /conversations/{id}/chat │ │
│ └────────────────────┬─────────────────────────────────────┘ │
└──────────────────────│─────────────────────────────────────────┘
│ HTTPS (nginx terminates TLS, user-managed)
▼
┌────────────────────────────────────────────────────────────────┐
│ ASP.NET Core 10 service (Program.cs + *Endpoints.cs) │
│ ┌─────────────────────┐ ┌─────────────────────────────────┐ │
│ │ Microsoft.Identity │ │ Semantic Kernel + MsGraph plugin│ │
│ │ .Web (JWT validate) │ │ ChatService → OpenAI-format │ │
│ └─────────────────────┘ │ POST to LLM_BASE_URL │ │
│ ┌─────────────────────┐ └─────────────────────────────────┘ │
│ │ EF Core 9 / Npgsql │ │
│ └──────────┬──────────┘ │
└────────────│──────────────────────────────────────────────────-┘
│
▼
┌──────────┐ ┌─────────────────────────────┐
│ Postgres │ │ Self-hosted LLM (your own) │
│ 16 │ │ Ollama / vLLM / LM Studio / │
└──────────┘ │ hosted OpenAI-compat proxy │
└─────────────────────────────┘
Office365SelfHostedLLMConnector/
├── Program.cs # Service composition, endpoint wiring, auth
├── ChatService.cs # Semantic Kernel + streaming loop + Graph plugin
├── ConversationEndpoints.cs # /documents, /conversations, /messages REST
├── AdminEndpoints.cs # /admin analytics + admin-user management
├── ImageService.cs # /images/generations for PowerPoint
├── SystemPrompts.cs # Per-host system prompts (doc-write / excel-query / excel-chart / ppt-action protocols)
├── SseHelpers.cs # Server-Sent-Events writer
├── AppDbContext.cs # EF Core model (Users, Conversations, Messages…)
├── User.cs / Document.cs / Conversation.cs / Message.cs / TokenUsageEvent.cs
├── DocumentIdentity.cs # 3-scheme identity normaliser
├── AdminAuth.cs # oid extraction + bootstrap-admin gate
├── Migrations/ # EF Core migrations
├── wwwroot/ # Static add-in assets served on the same origin
│ ├── index.html # — task pane shell + CSS
│ ├── js/ # — ES module source (entry: js/main.js)
│ │ ├── main.js # bootstrap: Office.onReady + module wiring
│ │ ├── state.js # shared mutable singleton (avoids circular imports)
│ │ ├── chat.js # send/stream, tool loop, write-back dispatch
│ │ ├── protocols.js # parseMarkdownTable, parseDocWrite, excel-query/chart parsers
│ │ ├── hosts.js # insertResponseIntoDocument, replaceWordBody, detectWordTarget
│ │ ├── excel.js # insertIntoExcel, runExcelQueries, insertExcelChart
│ │ ├── powerpoint.js # ppt-action dispatcher + Office.js bridge
│ │ ├── dom.js # appendActionPreviewBubble, chat transcript rendering
│ │ ├── conversations.js # conversation list CRUD + picker
│ │ ├── auth.js # MSAL.js sign-in / token refresh
│ │ ├── config.js # model picker, API URL
│ │ ├── mode.js # Plan / Ask / Edit mode
│ │ ├── attachments.js # file attachment handling
│ │ └── documentIdentity.js # document fingerprint + OneDrive identity
│ ├── taskpane.js # — legacy monolith (kept for one-line rollback; not loaded)
│ ├── admin.html / admin.js # — admin analytics dashboard
│ └── images/ # — branding assets
├── tools/
│ ├── esm-load-check.mjs # Node CI check: verifies the full JS module graph links
│ └── build-outlook-zip.sh # Builds + validates the unified Outlook manifest .zip
├── addin/ # Office Add-in manifests
│ ├── manifest.xml # — Word/Excel/PowerPoint
│ ├── manifest-outlook.xml # — Outlook
│ └── update-manifest-urls.sh # — rewrites <SourceLocation> for a new deploy URL
├── deploy/
│ └── nginx.example.conf # Reference TLS + reverse-proxy config (user-managed)
├── Office365SelfHostedLLMConnector.Tests/ # xUnit test project
├── Dockerfile # Two-stage build (sdk:10.0 → aspnet:10.0)
├── docker-compose.yml # app + postgres:16, healthchecks, named volume
└── .env.example # All required env vars, documented inline
- Microsoft 365 tenant with permission to register an Entra app and sideload Office Add-ins
- An OpenAI-compatible LLM endpoint you can hit (URL + API key — anything that speaks
POST /v1/chat/completionsworks) - Docker + docker-compose on the host (production deploy)
- .NET 10 SDK on your workstation (local dev only)
- nginx + a TLS cert in front of the container (Office Add-ins require HTTPS for the task pane URL)
# 1. Copy and fill in env vars
cp .env.example .env
# edit .env — at minimum set LLM_* and POSTGRES_PASSWORD
# 2. Start Postgres only
docker compose up -d postgres
# 3. Override the connection-string host for `dotnet run` on the host
# (compose service name "postgres" → "localhost")
export PG_CONNECTION_STRING="Host=localhost;Port=5432;Database=office365llm;Username=office365llm;Password=<from .env>"
# 4. Apply migrations and run
dotnet ef database update
dotnet run
# Service listens on http://localhost:8080 (or whatever ASPNETCORE_URLS says)To sideload the add-in in Word/Excel/PowerPoint on Mac, drop addin/manifest.xml into:
~/Library/Containers/com.microsoft.Word/Data/Documents/wef/
~/Library/Containers/com.microsoft.Excel/Data/Documents/wef/
~/Library/Containers/com.microsoft.Powerpoint/Data/Documents/wef/
then restart the host app. On Windows, follow Microsoft's sideloading guide.
For Outlook, see the dedicated Deploying the Outlook add-in section below — it's its own beast (two manifest formats, three install paths).
The intended topology is a single VM with Docker, nginx terminating TLS in front of the container, and the cloud firewall restricting inbound :8081 to the nginx host's private IP.
# On the server, in the repo root
cp .env.example .env && vi .env # fill in real LLM + Entra + Postgres values
docker compose up -d --build
docker compose logs -f app # watch boot + migrationsProgram.cs calls db.Database.Migrate() once at boot — schema changes deploy alongside code with no manual step. (If you scale out to multiple replicas, move migrations into a sidecar so two instances don't race.)
Point your add-in manifest's <SourceLocation> at your public HTTPS URL:
./addin/update-manifest-urls.sh https://your-add-in-host.example.comA reference nginx config (TLS + reverse-proxy + buffer-disabling for SSE) lives at deploy/nginx.example.conf.
Outlook is the painful host because there are two manifest formats and three install paths, and they don't all accept the same artifact. The rule that makes it reliable:
Prefer the unified manifest (
.zip) everywhere. Keep the legacy XML only for classic Mac Outlook and the scripted PowerShell deploy.
The unified package lives at addin/outlook-unified/ (source) and builds to addin/outlook-unified.zip. Rebuild + validate it with:
./tools/build-outlook-zip.shThat script hard-fails on the silent killers we kept hitting: wrong app-icon dimensions (color.png must be 192×192, outline.png 32×32), missing command icons (16/32/80), bad JSON, or a stale .zip whose embedded manifest drifted from source.
Admin Center → Settings → Integrated apps → Upload custom apps returns a generic "Upload failed" (HTTP 500) for every Office add-in, on every tenant — a Microsoft platform bug, office-js#5962. It is not your manifest. Manifests that work when sideloaded still fail there. Use the working routes below until Microsoft fixes it.
| Path | Artifact | How |
|---|---|---|
| Per-user / testing (works today) | manifest-outlook.xml (XML only — this dialog rejects the .zip) |
aka.ms/olksideload → My add-ins → Add a custom add-in → Add from file. Sideload loads the XML directly (no AATS), so it works even though AATS rejects the same XML for Centralized Deployment. |
| New Outlook (Win/Mac) + Outlook on the web | outlook-unified.zip |
Teams Developer Portal → Apps → Import app → upload the .zip → Preview in Outlook. |
| Org-wide (online) | outlook-unified.zip |
Teams Admin Center → Teams apps → Manage apps → Upload new app (the unified manifest is a Teams/MetaOS app, so it deploys here — a different backend from the broken Integrated apps page). Rolls out in ~6h. |
| Org-wide (scripted, bypasses every admin UI) | manifest-outlook.xml |
pwsh ./addin/deploy-outlook-addin.ps1 — talks to the Centralized Add-In Deployment service directly. |
A note on the legacy XML path: office-addin-manifest validate addin/manifest-outlook.xml currently returns AATS errors (Package Type Not Identified / Wrong Package). That validator hits the same flaky acceptance service behind the broken Admin Center; the PowerShell script above uses a different entry point, so it's the more reliable XML route.
A note on validation tooling: do not trust office-addin-manifest validate (or a raw JSON-schema check) on the unified manifest.json. Those validate against the public Teams schema, which models extensions as an array; Office add-ins use extensions as an object (requirements/runtimes/ribbons), which that schema doesn't model — so you'll always see a bogus "extensions must be array". ./tools/build-outlook-zip.sh checks the correct Office-add-in shape instead.
Other gotchas, learned the hard way:
- GUID caching. Outlook caches an add-in by its manifest
id. If an install fails as "duplicate", remove the prior copy first (OWA → Get Add-ins → My add-ins → Custom → ⋯ → Remove) before re-adding. The XML and unified manifests use different GUIDs and are treated as two separate add-ins. - Everything must be HTTPS and reachable. Both manifests point
SourceLocation/code.pageand all icon URLs athttps://omni365.funki.asia. If the task pane is blank on install, the host couldn't fetch that URL (cert, DNS, or the SSE/CSP nginx config). validDomains/AppDomainsmust list every domain the pane navigates to, or auth pop-ups silently fail.
All configuration is environment-variable only — no appsettings.json overrides ship to production. Full schema with inline rationale is in .env.example. The non-obvious ones:
| Var | Why it matters |
|---|---|
LLM_BASE_URL |
OpenAI-compatible root URL. No trailing slash. Anything that responds to POST /chat/completions works. |
LLM_CONTEXT_WINDOW |
Drives the "used / capacity" badge under each reply and gates compaction. Match your upstream's real limit. |
PG_CONNECTION_STRING |
Use Host=postgres from inside docker; Host=localhost for dotnet run on the host. |
AZURE_AD_TENANT_ID / AZURE_AD_CLIENT_ID |
Identify which Entra tenant + app's access_as_user scope to validate. |
ADMIN_BOOTSTRAP_OIDS |
Comma-separated Entra Object IDs that always have admin rights, independent of users.is_admin. Exists so a self-demote is recoverable by editing env + restarting — no SQL needed. |
The add-in mints two tokens with MSAL.js:
- An app-scoped token for the
access_as_userscope on this app's Entra registration — sent asAuthorization: Bearer …to every backend endpoint, validated byMicrosoft.Identity.Web. (ADR 0001) - A Microsoft Graph token — sent in the chat request body and forwarded to Semantic Kernel's MsGraph plugin so the LLM can call Graph (mail, calendar, OneDrive) on the user's behalf without the server ever holding Graph credentials of its own.
The first-time /me hit creates the user row (looked up by oid claim — never NameIdentifier, which is the per-app pairwise sub).
| Endpoint | Purpose |
|---|---|
GET /me |
First-touch user provisioning; returns {id, oid, email, displayName, isAdmin} |
POST /chat |
Stateless streaming chat (used when document identity resolution fails client-side) |
GET/PUT /documents/{id} |
Document upsert (lazy) |
GET/POST /documents/{id}/conversations |
List / create conversations on a document |
POST /conversations/{id}/chat |
Persisted streaming chat — writes Message rows + TokenUsageEvent |
GET /conversations/{id}/messages |
Replay history (filters archived behind a flag) |
PATCH/DELETE /conversations/{id} |
Edit metadata / delete (owner-only) |
POST /images/generations |
Image generation for PowerPoint deep-integration |
/admin/* |
Analytics + admin-user management (gated by AdminAuth) |
/admin → /admin.html |
Convenience redirect |
All endpoints except /chat (legacy fallback) require a valid Entra JWT. /chat itself still requires auth — anonymous LLM spend would be a footgun.
- ESM refactor (v=41). The original monolith
taskpane.jshas been split into ES modules underwwwroot/js/. The entry point isjs/main.js(loaded as<script type="module">).taskpane.jsis kept on disk for emergency rollback — swap the<script>tag inindex.htmlback totaskpane.js?v=40if needed. - Add-in cache is brutal. Office WebView2 (Windows) and WKWebView (Mac) cache
js/main.jsaggressively and there's no hard-refresh shortcut. Bump the?v=NNquery string on<script src>in wwwroot/index.html on every meaningful client change. - Migrations sometimes need inline
[Migration]+[DbContext]attributes when hand-authored withoutdotnet ef migrations add— EF Core won't discover them otherwise. - EF Core 9's
PendingModelChangesWarningis configured toIgnoreinProgram.csso deploys aren't wedged by snapshot drift across branches. - Outbound LLM calls funnel through a custom
UserAgentOverrideHandlerbecause upstream proxies' WAFs sometimes block the OpenAI .NET SDK's default UA. See bottom ofProgram.cs.
# C# unit + integration tests (identity, sharing, compaction)
dotnet test
# JS smoke test — pure-function protocol helpers (no browser/Office required)
node smoke-test.mjs
# ESM module-graph integrity check (all imports resolve, no cycles break load)
node tools/esm-load-check.mjsC# unit + integration tests live in Office365SelfHostedLLMConnector.Tests/. Run them before any PR that touches identity, sharing, or compaction.
The JS smoke test (smoke-test.mjs) imports directly from wwwroot/js/protocols.js and exercises parseMarkdownTable, parseDocWrite, parseExcelQueries, parseExcelCharts, and the sheet statistics helpers. Run it after any change to protocols.js or excel.js.
MIT License — Copyright (c) 2026 elohcrypto