Hugo Fund Formation Platform16 Apr, 05:03 CET

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.
3
Tabs
8
Filter dimensions
5
Chat tools
Live
Status
New: Comparable LPs tab. Pick a profile (investor type × country × commitment size × fund type × vintage) and see what clauses comparable LPs at the firm have typically received, with frequency (e.g. "8 of 9 received this clause"), a low/high range that honestly reports sample size, and a delta vs the fund-wide baseline. One-click insert of the modal version of any clause, or "Insert all (in order)" to seed a draft side letter from the comparable LPs in the corpus.
Chat is gated. The add-in performs zero AI calls until 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.

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.

PlatformHow
Word for Mac
mkdir -p ~/Library/Containers/com.microsoft.Word/Data/Documents/wef
curl -o ~/Library/Containers/com.microsoft.Word/Data/Documents/wef/hugo-clauses.xml \
  https://hugo.nordiclawfirm.com/addin/word/manifest.xml
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

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

ParamTypeSQL / 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:

API endpoint reference

MethodPathPurpose
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:

3-pass dispatch flow
  1. 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. When CHAT_LIVE is 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.
  2. Tool execution — the dispatcher mints an internal app.fetch() request that re-enters /api/clauses/search with 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.
  3. Synthesis — turn the tool results into a natural-language answer. CHAT_LIVE=1 path: 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.

1. search_clauses

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

2. count_clauses

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

3. aggregate_clauses

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

4. list_funds

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

5. get_clause

Use 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

  1. Backend (src/index.tsx, inside clauseSearchHandler):
    • 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.
  2. Backend (src/index.tsx, /api/chat/context): add a new entry to the dimensions array with a plain-English description.
  3. 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.
  4. Frontend types (office-addins/word/src/types.ts): add the field to ClauseFilters.
  5. Frontend api (src/api.ts): add the field to the dims list inside filtersToParams.
  6. Frontend state (src/taskpane.ts): add the field to the FilterDim union, the state.filters initializer, the labels map, DIM_DISPLAY, DIM_ORDER, and either STATIC_OPTIONS (for hardcoded enums) or getDimOptions (for facet-driven ones).
  7. Frontend HTML — nothing to add: the pill grid is built dynamically from DIM_ORDER.
  8. Frontend bootstrap (bootstrapClauses): if the new dimension needs a facet endpoint, add the fetch + populate the label map.
  9. Backend facet endpoint (optional): add GET /api/<dim> returning {results: [{id, name, count}]}.

Add a new tool

  1. In src/index.tsx: add a new entry to the ChatIntent['tool'] union and a new case branch in executeChatTool.
  2. 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.
  3. Add the tool's name and description to the buildIntentSystemPrompt "AVAILABLE TOOLS" section so the live router knows about it.
  4. Add a case to the offline extractIntentOffline matcher's tool-selection heuristic so the offline path can route to it on a recognizable phrase.
  5. Add a case to synthesizeOffline so the templated synthesis can render the tool's result shape.
  6. Document the tool here (in this page). Cookbook lives in code; reference lives in docs.

Wire the live model (Heimdall)

  1. Set CHAT_LIVE = "1" in hugo-worker/wrangler.toml [vars] section.
  2. Verify AI_GATEWAY_ID and CHAT_MODEL are set (already in production: hugo-gateway + heimdall/sonnet).
  3. If Heimdall has bearer auth enabled: npx wrangler secret put HEIMDALL_API_TOKEN.
  4. Redeploy. The next chat request will route through Heimdall instead of the offline matcher.
  5. Watch npx wrangler tail hugo for 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

The add-in is structurally guaranteed to incur zero Cloudflare AI cost in its current shipped state.

Known limitations

  1. 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.
  2. Single-fund clause search is per-fund. The legacy /api/funds/:fundId/clauses/search stays for backward compatibility; new code should use the cross-fund /api/clauses/search.
  3. Plain-text insertion only. Clauses are TEXT in D1; rich formatting would need insertOoxml + a structured storage shape.
  4. No automated E2E. Playwright cannot drive the Word ribbon. Smoke testing is browser-based against /addin/word/taskpane.html with extraHTTPHeaders for the CF Access service token; manual sideload is the only real Word verification.

Related

Ctrl+K to open · ↑↓ navigate · Enter go · Esc close
Copied