Word Add-in
A Microsoft Word task pane that lets fund lawyers browse the Hugo clause library, filter it across 8 dimensions, look up what clauses comparable LPs typically receive, insert clauses into the active LPA / side letter, and (soon) ask BI questions in natural language.CHAT_LIVE=1 is set in the worker env. Until then the chat tab runs an offline keyword matcher + templated synthesis. The Clauses tab is fully live and incurs zero AI cost.
Contents
Repository layout
Lives at ~/projects/hugo-folder/hugo-worker/office-addins/word/. Self-contained — no shared library, no workspace. Patterned on the Gustav DMS Word add-in but inlined and re-skinned with the same design tokens.
office-addins/word/
├── manifest.xml # Office add-in manifest (sideload this)
├── package.json
├── tsconfig.json
├── webpack.config.js # HTTPS dev server on :3000, ts-loader, style-loader
├── README.md # Build & sideload instructions
├── .gitignore
├── assets/ # Ribbon icons 16/32/80
│ ├── icon-16.png
│ ├── icon-32.png
│ └── icon-80.png
└── src/
├── taskpane.html # Tab shell, Office.js script tag, filter pill grid
├── taskpane.css # Imports ./styles/base.css; Hugo-specific overrides
├── taskpane.ts # Bootstrap, tabs, clauses tab, filter pills, chat tab
├── api.ts # fetch wrappers for Hugo API (facets, search, chat)
├── types.ts # ClauseFilters, FacetRow, ClauseSearchHit, …
└── styles/
└── base.css # Design tokens (sky blue, warm grey, 6px radii)
Bundle target: dist/taskpane.bundle.js (~40 KB minified). Production assets are committed to hugo-worker/public/addin/word/ and served by the main Hugo Worker via [assets] directory = "./public".
Local development
Prerequisites (one-time)
cd ~/projects/hugo-folder/hugo-worker/office-addins/word
npm install
npx office-addin-dev-certs install # trusts the local HTTPS cert
Run two terminals
# Terminal 1 — Hugo backend (HTTPS so the add-in can fetch it without
# mixed-content errors).
cd ~/projects/hugo-folder/hugo-worker
npx wrangler dev --local --local-protocol https --port 8788
# Terminal 2 — add-in dev server (HTTPS on :3000, required by Office.js)
cd ~/projects/hugo-folder/hugo-worker/office-addins/word
npm run dev
Test in a plain browser (no Word required)
Useful for fast iteration on this VPS, where Word isn't available:
https://localhost:3000/taskpane.html?api=https://localhost:8788
Accept both self-signed certs once. The add-in shows a BROWSER badge and disables Word-specific actions (Insert shows a toast instead of throwing).
Build & deploy to prod
cd ~/projects/hugo-folder/hugo-worker/office-addins/word
npm run build # webpack production bundle
cp -r dist/* ../../public/addin/word/ # copy into Hugo's static assets
cp manifest.xml ../../public/addin/word/manifest.xml
cd ../..
npx wrangler deploy # ships everything in one go
Sideloading on Mac / Windows / Web
The deployed manifest at https://hugo.nordiclawfirm.com/addin/word/manifest.xml already points at the production assets. To sideload elsewhere edit manifest.xml first.
| Platform | How |
|---|---|
| Word for Mac |
Quit Word fully (Cmd-Q), reopen → Insert → My Add-ins → Developer Add-ins → Hugo Clauses.
|
| Word for Windows | npx office-addin-debugging start manifest.xml word from the add-in folder. Or use a Shared Folder catalog and drop the manifest there. |
| Word Online | word.cloud.microsoft → open any doc → Insert → Add-ins → Upload My Add-in → pick manifest.xml. |
Production hosting model
The add-in is served as static assets from the production Hugo Worker, mirroring how Gustav hosts its Office add-in. Two pieces:
1. Static asset binding
# hugo-worker/wrangler.toml
[assets]
directory = "./public"
html_handling = "none" # keep .html literal so Office manifest URLs don't 307
2. CF Access path bypass
Hugo's CF Access app gates the entire hugo.nordiclawfirm.com origin. A second Access app with a more-specific scope hugo.nordiclawfirm.com/addin carries a single bypass: everyone policy at precedence 1, so the add-in's manifest, taskpane HTML, and bundle are reachable from any sideloaded Word session without an Access challenge:
App id: f7369463-e1ad-44ef-8c4d-511a1e4e558b
Domain: hugo.nordiclawfirm.com/addin
Policy: {"decision":"bypass","include":[{"everyone":{}}],"precedence":1}
The rest of hugo.nordiclawfirm.com stays gated. /api/* stays behind Access — only /addin/* is bypassed. The add-in fetches API endpoints same-origin with credentials: 'same-origin', so the user's existing CF Access JWT cookie rides along automatically.
Why this model
- One redeploy ships add-in updates to every user instantly. No per-machine reinstall.
- Manifest URLs are stable and HTTPS — Word for Mac, Windows, and Online all accept them.
- No tunnel, no localhost certs, no per-user setup beyond a one-time manifest sideload.
- Same auth surface as the rest of Hugo — no separate identity for the add-in.
Filter dimensions
The Clauses tab supports 8 combinable filter dimensions plus a free-text search. Each dimension is OR within itself (multi-select within one dimension), and dimensions are AND across. The whole filter spec is sent to GET /api/clauses/search as repeated query params (e.g. ?clause_name_id=cn_mfn&clause_name_id=cn_erisa&investor_category_id=ic_swf).
| Param | Type | SQL / join path |
|---|---|---|
q |
string | FTS5 clauses_fts MATCH ?, falls back to c.text LIKE '%q%' on FTS5 failure |
fund_id |
string[] | c.fund_id IN (…) |
fund_category_id |
string[] | f.category_id IN (…) via JOIN funds f |
sponsor_id |
string[] | f.sponsor_id IN (…) — canonical since 0056 |
gp_name (legacy) |
string[] | Resolved to f.sponsor_id IN (SELECT id FROM sponsors WHERE name IN (…)). Accepted for backward compat; prefer sponsor_id. |
vintage_year |
number[] | f.vintage_year IN (…) — bound as integers |
clause_name_id |
string[] | c.clause_name_id IN (…) |
clause_group_id |
string[] | c.clause_group_id IN (…) — variant group within a clause name |
investor_id |
string[] | EXISTS (… clause_assignments → commitments → lps WHERE l.investor_id IN (…)) |
investor_category_id |
string[] | EXISTS (… → lps → investors WHERE i.category_id IN (…)) |
lp_country_code |
string[] | EXISTS (… → lps WHERE l.country_code IN (…)) — ISO-2 codes |
lifecycle_status |
string[] | c.lifecycle_status IN (…) — draft / live / published / archived |
source |
string[] | c.source IN (…) — manual / parsed / ai / corpus / fork |
Output modes
Two extra query params switch the response shape:
?count_only=1— returns{"count": N}instead of rows. Use for "how many" questions.?group_by=<dim>— returns{"results": [{key, label, count}, …]}sorted by count desc. Allowed dims:clause_name_id,clause_group_id,fund_id,fund_category_id,gp_name,vintage_year,lifecycle_status,source.
API endpoint reference
| Method | Path | Purpose |
|---|---|---|
| GET | /api/funds |
List funds the user can access (with stats). Used for the fund pill facet. |
| GET | /api/clause-names |
Distinct clause types, ordered by sort_order. {id, name, sort_order, clause_count}. |
| GET | /api/investor-categories |
Investor type taxonomy. {id, name, sort_order, investor_count}. |
| GET | /api/fund-categories |
Fund strategy buckets. {id, name, sort_order, fund_count}. |
| GET | /api/gps |
Distinct GP / sponsor names from funds.gp_name. {id, name, fund_count} (id == name). |
| GET | /api/lp-countries |
Distinct LP jurisdictions from lps.country_code. {id, name, lp_count}. |
| GET | /api/clauses/search |
Primary filtered search. Accepts all 12 filter params + limit, count_only, group_by. See above for shape. |
| GET | /api/clauses/:clauseId |
Full clause + assignments + MFN exclusions + ops tags. Used for the detail panel. |
| GET | /api/lp-profile/facets |
Form facets for the Comparable LPs tab: investor_types, lp_countries, fund_types, size_bands, vintage_range. |
| GET | /api/lp-profile |
Comparable LPs lookup. Inputs: category, country, size, fund_category, vintage_from, vintage_to. Returns comparable_lp_count, total_matching_lps, is_baseline, and clauses[] ranked by received_count with frequency_pct, low_pct, high_pct, baseline_pct, delta_pct, and a variants[] array sorted by use_count desc. |
| GET | /api/chat/context |
Schema preamble for the chat router: 13 dimension definitions, current enums, accessible funds, hard rules. Used to seed the LLM system prompt. |
| POST | /api/chat |
BI dispatcher. Body: {messages: [{role, content}, …]}. See Chat dispatcher below. |
Chat dispatcher
The chat tab posts the conversation history to POST /api/chat on every turn. The handler runs a 3-pass dispatch regardless of whether the live model is wired:
- Intent extraction — translate the user question into a structured tool spec. When
CHAT_LIVE=1, this is a Heimdall call with the full schema preamble in the system prompt. WhenCHAT_LIVEis unset (the default today), an offline keyword matcher resolves the question against the resolved enums (clause names, investor categories, fund categories, GPs, vintages, country names) using word-boundary regex matching plus initials. - Tool execution — the dispatcher mints an internal
app.fetch()request that re-enters/api/clauses/searchwith the chosen filters and output mode. This keeps a single source of truth for filter parsing, FTS5 fallback, and fund-access enforcement. Auth headers are forwarded from the original request so the same user is seen by the auth middleware. - Synthesis — turn the tool results into a natural-language answer.
CHAT_LIVE=1path: a second Heimdall call with system prompt "you answer BI questions about a fund-formation clause corpus, using ONLY this data, with clause/fund ID citations". Offline path: a deterministic template that prints the dispatched intent, the filter spec, and a sample of the result rows.
Why two passes (and not Anthropic tool_use)
Heimdall is OpenAI-compatible text-only — the underlying claude -p CLI doesn't expose tool_use through the proxy. So instead of one round-trip with native tool calls, we do JSON intent → execute → synthesize. Both passes go through the same runChat() helper in src/lib/ai.ts; both are gated behind the same env flag.
Response shape
{
"assistant": "Across our 4 funds with 2024 vintage, the modal SFDR clause is Article 8 (3 funds), with Article 8 Plus appearing in 1 fund (cls_cvx9_sfdr_v1)…",
"trace": {
"intent": {
"tool": "aggregate_clauses",
"filters": { "clause_name_id": ["cln_sfdr"], "vintage_year": [2024] },
"group_by": "clause_group_id",
"max_results": 25,
"summary_focus": "What is market standard for SFDR across our 2024 closes?"
},
"tool_result": { "kind": "aggregate", "rows": 1 }
},
"latency_ms": 64
}
The taskpane renders the trace as a small breadcrumb above the assistant bubble (e.g. 🔧 aggregate_clauses · clause_name_id=cln_sfdr · vintage_year=2024 · group_by=clause_group_id → 1 rows) so users can see exactly which dispatch the router chose.
Enabling the live model
# In hugo-worker/wrangler.toml [vars] section
CHAT_LIVE = "1"
# Or via secret if you want to flip it without a redeploy:
npx wrangler secret put CHAT_LIVE # value: 1
Until that's set, every chat turn returns the offline-templated answer. The chat tab is fully functional in offline mode — you can verify dispatch correctness, look at intent traces, and exercise every tool path without any AI traffic.
Tool registry
Five tools are exposed to the chat router. Each is dispatched by the tool field of the intent JSON. Filters apply to all tools that accept them; arguments are resolved client-side (in the Worker) before reaching D1.
search_clausesUse for: "show me", "list", "give me examples". Returns full clause rows.
Args: filters (any of the 12 dimensions), max_results (default 25, max 100).
Returns: { results: ClauseRow[], total: number } where ClauseRow = {id, fund_id, fund_name, fund_category_name, clause_name, clause_name_id, text, version, lifecycle_status, source, assignment_count}.
Underlying call: GET /api/clauses/search?{filters}&limit=N.
count_clausesUse for: "how many", "what proportion", "is there at least one…". Avoids dragging full rows through the model.
Args: filters.
Returns: { count: number }.
Underlying call: GET /api/clauses/search?{filters}&count_only=1.
aggregate_clausesUse for: "what's the breakdown", "what variants exist", "which fund/GP/category has the most", "market standard" questions (group by clause_group_id + filter by clause_name_id to see how a single clause type splits across canonical variants).
Args: filters, group_by ∈ {clause_name_id, clause_group_id, fund_id, fund_category_id, gp_name, vintage_year, lifecycle_status, source}.
Returns: { results: [{key, label, count}, …] } sorted by count desc.
Underlying call: GET /api/clauses/search?{filters}&group_by=<dim>.
list_fundsUse for: questions about funds themselves rather than clauses. Also useful when the model needs to know which vintages exist before downstream filtering.
Args: none (returns all funds the user can access).
Returns: { results: [{id, name, gp_name, vintage_year}, …] }.
Underlying call: direct D1 read against funds JOIN user_funds.
get_clauseUse for: when the user references a specific clause ID and wants the full text.
Args: clause_id.
Returns: {id, fund_id, fund_name, clause_name, text, lifecycle_status, source, version}.
Underlying call: direct D1 read with the standard joins.
Chat context endpoint
The dispatcher's system prompt is built fresh on every request from /api/chat/context, which returns:
{
"schema_version": "1.0.0",
"generated_at": "2026-04-12T22:47:11.000Z",
"description": "Hugo clause-corpus chat context. …",
"dimensions": [
{ "param": "q", "type": "string", "description": "…" },
{ "param": "fund_id", "type": "string[]", "description": "…" },
{ "param": "fund_category_id", "type": "string[]", "description": "…" },
{ "param": "gp_name", "type": "string[]", "description": "…" },
{ "param": "vintage_year", "type": "number[]", "description": "…" },
{ "param": "clause_name_id", "type": "string[]", "description": "…" },
{ "param": "investor_id", "type": "string[]", "description": "…" },
{ "param": "investor_category_id", "type": "string[]", "description": "…" },
{ "param": "lp_country_code", "type": "string[]", "description": "…" },
{ "param": "lifecycle_status", "type": "string[]", "description": "…" },
{ "param": "source", "type": "string[]", "description": "…" },
{ "param": "count_only", "type": "0|1", "description": "…" },
{ "param": "group_by", "type": "enum", "description": "…" }
],
"enums": {
"clause_names": [ {id, name}, … ],
"investor_categories": [ {id, name}, … ],
"fund_categories": [ {id, name}, … ],
"gps": [ {id, name}, … ],
"lp_countries": [ {id, name}, … ]
},
"accessible_funds": [ {id, name, gp_name, vintage_year}, … ],
"rules": [
"Only answer using the tool results. …",
"Always cite clause IDs and fund IDs in your answer …",
"For 'how many' / 'what proportion' questions, prefer count_only or group_by …",
"Resolve plain-English entity names (e.g. 'sovereign wealth', 'German LPs', 'Northstar VIII') to IDs …"
]
}
The full preamble is ~1.5–2k tokens once enums are inlined. Cached client-side per session in the production model path. The same payload is also stitched into the offline matcher's resolution tables.
Extending: cookbook
Add a new filter dimension
- Backend (
src/index.tsx, insideclauseSearchHandler):- Read the param:
const fooIds = all('foo_id'); - Add a WHERE clause with parameterized placeholders:
where.push(`c.foo IN (${fooIds.map(() => '?').join(',')})`); binds.push(...fooIds); - If the filter requires joins outside the main FROM (e.g. through clause_assignments), use an
EXISTS (…)subquery so you don't multiply rows.
- Read the param:
- Backend (
src/index.tsx,/api/chat/context): add a new entry to thedimensionsarray with a plain-English description. - Backend (offline matcher inside
/api/chat): add a resolver block that maps user words to the filter values. Use word-boundary regex matching, not substring. - Frontend types (
office-addins/word/src/types.ts): add the field toClauseFilters. - Frontend api (
src/api.ts): add the field to thedimslist insidefiltersToParams. - Frontend state (
src/taskpane.ts): add the field to theFilterDimunion, thestate.filtersinitializer, thelabelsmap,DIM_DISPLAY,DIM_ORDER, and eitherSTATIC_OPTIONS(for hardcoded enums) orgetDimOptions(for facet-driven ones). - Frontend HTML — nothing to add: the pill grid is built dynamically from
DIM_ORDER. - Frontend bootstrap (
bootstrapClauses): if the new dimension needs a facet endpoint, add the fetch + populate the label map. - Backend facet endpoint (optional): add
GET /api/<dim>returning{results: [{id, name, count}]}.
Add a new tool
- In
src/index.tsx: add a new entry to theChatIntent['tool']union and a newcasebranch inexecuteChatTool. - If the tool needs a new SQL shape, write a fresh internal helper rather than overloading
clauseSearchHandler— the latter is the source of truth for the filter/group/count semantics, don't bend it. - Add the tool's name and description to the
buildIntentSystemPrompt"AVAILABLE TOOLS" section so the live router knows about it. - Add a case to the offline
extractIntentOfflinematcher's tool-selection heuristic so the offline path can route to it on a recognizable phrase. - Add a case to
synthesizeOfflineso the templated synthesis can render the tool's result shape. - Document the tool here (in this page). Cookbook lives in code; reference lives in docs.
Wire the live model (Heimdall)
- Set
CHAT_LIVE = "1"inhugo-worker/wrangler.toml[vars]section. - Verify
AI_GATEWAY_IDandCHAT_MODELare set (already in production:hugo-gateway+heimdall/sonnet). - If Heimdall has bearer auth enabled:
npx wrangler secret put HEIMDALL_API_TOKEN. - Redeploy. The next chat request will route through Heimdall instead of the offline matcher.
- Watch
npx wrangler tail hugofor any 5xx from the gateway URL — common causes are gateway misconfig (custom-heimdall slug missing) or Heimdall systemd unit being down on Odin.
Cost-safety guarantees
- No
wrangler.tomlinoffice-addins/word/→ no CF bindings can leak in. - No imports from
hugo-worker/src/lib/ai*, no Workers AI / Vectorize / Anthropic / AI Gateway references anywhere in the add-in'ssrc/. - The Clauses tab makes only D1 reads via the existing
/api/clauses/searchhandler — zero AI in the hot path. - The Chat tab runs the offline keyword matcher + templated synthesis until
CHAT_LIVE=1. NorunChat()call is reachable in the default config. - Heimdall is the eventual provider (Axel's Max subscription, fixed monthly cost) — even when wired, marginal per-message cost is zero.
Known limitations
- Offline matcher is approximate. Word-boundary regex matching against enum names + initials handles the canonical question shapes, but ambiguous tokens (e.g. "sovereign" appearing in both an investor category and a clause type) can over-match. The model path resolves these correctly with full context.
- Single-fund clause search is per-fund. The legacy
/api/funds/:fundId/clauses/searchstays for backward compatibility; new code should use the cross-fund/api/clauses/search. - Plain-text insertion only. Clauses are
TEXTin D1; rich formatting would needinsertOoxml+ a structured storage shape. - No automated E2E. Playwright cannot drive the Word ribbon. Smoke testing is browser-based against
/addin/word/taskpane.htmlwithextraHTTPHeadersfor the CF Access service token; manual sideload is the only real Word verification.
Related
- Word Add-in — Sources — annotated bibliography of Microsoft Learn / OfficeDev / MVP best-practice references (28 sections, ~120 verified links)
- API Reference — every JSON endpoint Hugo exposes
- Clause Export API — bulk export for offline analysis
- Authentication — how the cookie + CF Access fallback works