Skip to content

elohcrypto/Office365SelfHostedLLMConnector

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

26 Commits
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

Office365SelfHostedLLMConnector

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.


What it does

  • 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.docx next week and your past threads on that file are right there.
  • Opt-in thread sharing. A conversation owner can flip a thread from private to shared; 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-write response 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 /chat calls 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 summary message and marked archived — never deleted.

Architecture at a glance

┌────────────────────────────────────────────────────────────────┐
│  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  │
                                   └─────────────────────────────┘

Repository layout

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

Prerequisites

  • 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/completions works)
  • 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)

Quick start (local dev)

# 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).


Production deploy

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 + migrations

Program.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.com

A reference nginx config (TLS + reverse-proxy + buffer-disabling for SSE) lives at deploy/nginx.example.conf.


Deploying the Outlook add-in

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.sh

That 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.

⚠ The M365 Admin Center "Integrated apps" page is broken right now

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/olksideloadMy 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 PortalApps → Import app → upload the .zipPreview in Outlook.
Org-wide (online) outlook-unified.zip Teams Admin CenterTeams 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.page and all icon URLs at https://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 / AppDomains must list every domain the pane navigates to, or auth pop-ups silently fail.

Configuration

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.

How auth works

The add-in mints two tokens with MSAL.js:

  1. An app-scoped token for the access_as_user scope on this app's Entra registration — sent as Authorization: Bearer … to every backend endpoint, validated by Microsoft.Identity.Web. (ADR 0001)
  2. 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).


API surface

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.


Development notes

  • ESM refactor (v=41). The original monolith taskpane.js has been split into ES modules under wwwroot/js/. The entry point is js/main.js (loaded as <script type="module">). taskpane.js is kept on disk for emergency rollback — swap the <script> tag in index.html back to taskpane.js?v=40 if needed.
  • Add-in cache is brutal. Office WebView2 (Windows) and WKWebView (Mac) cache js/main.js aggressively and there's no hard-refresh shortcut. Bump the ?v=NN query string on <script src> in wwwroot/index.html on every meaningful client change.
  • Migrations sometimes need inline [Migration] + [DbContext] attributes when hand-authored without dotnet ef migrations add — EF Core won't discover them otherwise.
  • EF Core 9's PendingModelChangesWarning is configured to Ignore in Program.cs so deploys aren't wedged by snapshot drift across branches.
  • Outbound LLM calls funnel through a custom UserAgentOverrideHandler because upstream proxies' WAFs sometimes block the OpenAI .NET SDK's default UA. See bottom of Program.cs.

Tests

# 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.mjs

C# 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.


License

MIT License — Copyright (c) 2026 elohcrypto

About

An Office Add-in that surfaces your own self-hosted LLM inside Word, Excel, PowerPoint, and Outlook — with per-user conversation history, Microsoft Graph integration, and admin token analytics.

Resources

License

Stars

Watchers

Forks

Releases

No releases published

Packages

 
 
 

Contributors