Tempest Capital, S.C. · Av. Paseo de la Reforma 296, Piso 25, Col. Juárez, 06600 CDMX · T. +52 (55) 7100 4570
Internal analytical work product — not investment, legal, tax, or accounting advice.
An Office.js (Excel) add-in that brings the Tempest institutional formatting
convention and a governed AI assistant into Excel itself. It is the client half
of Typhoon ITEM 1. The server half is scripts/addin_commands.py in this
repo, reached over a small local Typhoon bridge service.
The add-in surfaces three things:
scripts/multi_agent PROCESSES
(WACC, capital structure, covenants, stress, accounting-standard scaffold,
…) and renders the reconciled findings as accept/reject cards.Every path that could change the workbook goes through one chokepoint:
bridge.ts → POST /command. The server returns a governed format spec (see
§4a); nothing else may format a cell. That contract:
command_id present in COMMAND_REGISTRY (raises KeyError
→ HTTP 422 otherwise), andfont / number_format / border
/ fill). No path reads a cell value for mutation or writes a cell .value.One registry, two renderers (P3-2). The spec is built once, server-side, by
scripts/format_spec.build_spec (keyed by the governed registry) and rendered
two ways from that single source of truth:
bridge.ts applies the returned spec to the
live selection via Excel.run (range.format.* / range.numberFormat /
per-cell font color for AutoColor). Changes appear instantly and Excel’s own
undo reverts them. The bridge never opens, saves, or reloads the file.scripts/format_spec.apply_spec writes the
same spec into a workbook the analyst is not holding open (the CLI reformat
/ build flows, via scripts/addin_commands.apply_command). This is the ONLY
writer for closed files.The add-in never invents formatting: the colors and number formats in a spec are
the Tempest convention values the server put there. The single thing computed
locally is which governed bucket color a cell receives under AutoColor (a
content-type classification of the live cell) — a faithful mirror of the Python
classifier in scripts/styles/autocolor.py; that Python classifier remains
authoritative for closed-file reformats.
Firm cloud chat (chat.ts) is read-only and returns no proposals. The
engineer-local Designer protocol can render a FormatProposal, but it never
writes cells directly; accepted formatting still routes through
bridge.applyFormatProposal. There is deliberately no range.values = …
write path anywhere in src/.
Trust boundary (be honest about it). The accept-gate is a client-side
control: applyFormatProposal(proposal, approved) takes the human decision as a
boolean, and the bridge applies whatever on-registry, formatting-only command it
is sent. What the server guarantees is narrower and absolute — on-registry
(422 gate) + formatting-only — not “a human approved.” For the real threat
model (local loopback, bearer token, single analyst, formatting-only, journaled,
Ctrl+Z-able) that is the right seam; it is documented here so no one over-relies
on the gate as a server-enforced control. A stronger guarantee, if ever needed,
is the local-only, nonce-required /proposal/command exchange retained for a
future governed proposal producer; it is absent from cloud.
System of record. The in-workbook journal (below) is a convenience audit
aid — it round-trips inside the .xlsx and is therefore user- and add-in
clearable, not tamper-evident. Structured bridge telemetry records request
metadata, not workbook changes; treat History as a local session aid.
The legal command ids are exactly the keys of COMMAND_REGISTRY in
scripts/addin_commands.py. Groups: color, number, border, font,
fill. Each command delegates to an existing Tempest primitive
(scripts.styles.autocolor classifier, scripts.styles.formats number
formats, scripts.styles.borders, FONT_DATA). The add-in discovers the
registry at runtime via GET /commands, so the button set stays in sync with
the Python source of truth.
This section describes the engineer-only local build. The firm cloud build
uses MSAL NAA and Azure (§4d) and cannot read stored loopback credentials.
The local add-in talks to a loopback HTTP service (default
http://127.0.0.1:8765 — plain HTTP, bound to 127.0.0.1 only; see
scripts/bridge_server.py, which matches runtime.ts getBridgeBase() and the
pane’s CSP connect-src) that wraps this repo. The service is NOT part of this
add-in package; it is a thin adapter that imports scripts.addin_commands and
scripts.multi_agent.
Every call — including GET /health — carries Authorization: Bearer <token>
(bridge_server.py authorizes every endpoint except loopback GET /handoff).
The per-session token is minted by typhoon serve and written to a mode-0600
handoff file (typhoon-bridge-<port>.json under $XDG_RUNTIME_DIR or the OS
temp dir; see scripts/_bridge_token.py). The taskpane auto-claims that
token via GET /handoff when the daemon is running (Office WebViews cannot
read the file directly); manual paste remains a fallback. Without a live
daemon, authed calls return 401 and the connection chip reads disconnected.
Local bridge CORS defaults to loopback origins only. A GH-Pages-hosted pane
talking to a local typhoon serve must set
TEMPEST_ADDIN_CORS_ORIGINS=https://tempest-capital.github.io on the bridge
process; otherwise Origin checks return 403 and look like a daemon fault.
| Endpoint | Method | Purpose |
|---|---|---|
/health |
GET | Liveness for the connection chip (bearer-authed like every endpoint) |
/commands |
GET | Serialize list_commands() → [{id,label,group,arity}] |
/command |
POST | {command_id, workbook, sheet, range, kwargs} → governed format spec (see §4a); 422 on unknown id. NEVER opens a workbook |
/processes |
GET | Serialize scripts.multi_agent.processes.PROCESSES → [{process_id,name,rationale}] |
/process |
POST | {process_id, context} → run process; returns reconciled {findings:[…]} |
/chat |
POST | {messages:[…], context:ContextPack} → read-only assistant reply {text, proposals:[]} |
/audit |
POST | {path} → scripts.verifier.lite_audit (READ-only) → ADVISORY {trust_tier, trust_label, composite_score, scorecard, checks}. 404 missing / 422 non-xlsx (P3-5) |
/classify |
POST | {path?} → the mapper review queue (crosswalk_review.csv) as {cards:[…], count, status}; alternatives/row_context parsed (P3-5) |
/classify/decide |
POST | {path?, source_label, decision:accept\|relabel, code?, decided_by?} → HUMAN decision written back to the queue CSV only (never a built workbook). 404 unknown label / 422 bad decision (P3-5) |
/scope |
POST | {sector\|sectors, deal_type?} → the SHARED scope gate {active_tabs, core_tabs, tier, is_deep, deep_threshold} from sector_router (the exact functions the CLI uses). 422 unknown sector (P3-5) |
/build |
POST | {sector, out?, simplify?, deal_type?} → the staged ledger {stages:[scaffold,wiring,verify], cli, note, status:"run_via_cli"}. STUB: a multi-minute build runs via the CLI (returned command); the async build-job endpoint is the P3-5b follow-up |
/verify |
POST | {path, sector?, distribution?, gaap_template?, recalc?, ai_review?} → the P2-1 ship gate via run_final_verifier → {ship_decision, exit_code, totals, findings:[…]} with FAIL/WARN findings shaped as JUMP-TO-CELL cards ({sheet, cell, address}). 404 missing / 422 unreadable (P3-5) |
The full request/response JSON shapes are documented in src/bridge.ts (command
path) and scripts/_bridge_buildverify.py (Build/Verify path).
The production bundle is compiled cloud with an exact ACA origin. MSAL
createNestablePublicClientApplication acquires the custom
access_as_user scope silently; only the visible Sign in action may open a
popup. The API verifies RS256 plus tenant, GUID audience, v2 issuer, delegated
scope, user oid and the Typhoon.User app role. Cloud exposes probes,
/health, /commands, /command and read-only /chat;
local Build/Verify, Designer, Consult and attachments are hidden.
src/buildverify.ts (the third task-pane tab) drives the SAME harness the CLI
uses, so the analyst never leaves Excel (wp4 finding 9):
.xlsx → POST /audit renders the
read-only, advisory trust tier (the lite-audit scores how a model is
built, never its economics; every consequential figure is still verified
against its primary source regardless of tier).POST /classify surfaces the semantic-mapper review queue as
accept/relabel cards; a decision posts back via POST /classify/decide.
Human-gated — the add-in never auto-resolves a mapping.POST /scope renders the core/full choice from the same
sector_router functions the CLI’s apply_scope_gate uses (not a copy).
A deep model (≥ deep_threshold tabs) must be confirmed before Build —
this is the governance point wp4 flagged; the tab never bypasses it.POST /build returns the staged ledger (scaffold → wiring →
verify) and the exact CLI command. A full multi-minute build is not run
synchronously through the loopback bridge in this release (no async job
runner / cancel yet); run the command shown, then Verify here. Follow-up:
P3-5b — an async build-job endpoint with a live progress ledger.POST /verify runs the P2-1 ship gate; FinalVerifier FAIL/WARN
findings render as jump-to-cell cards (click → Excel.run selects the
cell). Offline/bridge-down states render an inline “is typhoon serve
running?” notice, never a silent no-op.src/chat.ts builds a context pack via Excel.run and attaches it to
every POST /chat body as context:
{
"selection": { "address": "Sheet1!B2:D5", "values": [[…]] }, // or null
"sheets": ["Sheet1", "ACS", "LOG", …],
"usedRangeHeaders": { "sheet": "Sheet1", "headers": ["Item", "2024", "2025", …] },
"namedRanges": { "PTU_Rate": 0.1, "ModelOverride_wacc": "Sonnet 5", … },
"truncated": false // true if any of the caps below trimmed data
}
Read-only, capped, and best-effort: engineer-local mode can build the richer
selection/header/named-range pack shown here. Firm cloud mode strips it to
sheet names plus selection/used-range addresses before transport; the
server independently rejects values, formulas, comments and unknown keys.
_bridge_chat.serialize_context consumes either versioned schema as untrusted
data in the prompt. Nothing in this path writes to the workbook.
Cloud POST /chat is one bounded blocking response and explicitly rejects
streaming; local mode retains the SSE implementation. The pane progressively
reveals cloud text only after the complete response arrives.
Persistence. Thread history (role/text/proposals, capped at the last 40
turns) is written to Office.context.document.settings under the key
typhoon.chat.history.cloud or .local and saveAsync()‘d after every turn.
The mode namespace prevents local value-grounded history from entering a later
metadata-only cloud request. That store is
inherently scoped to the open workbook (it round-trips inside the .xlsx
file’s custom XML part), so history is “keyed to the workbook” without any
extra bookkeeping, and is restored (read-only cards, no accept/reject
buttons — see below) when the pane next loads. A workbook that has never seen
Typhoon chat simply has no key set; a corrupt/foreign value degrades to no
restored history rather than throwing.
Restored proposal cards are rendered without Accept/Reject buttons or hover-preview: after a pane reload there is no way to know whether a prior proposal was already applied, rejected, or left pending, so re-arming it could invite a confusing duplicate apply. They render as plain context (“(restored from a previous session — send a new message to act on this)”); a fresh proposal always requires a fresh, interactive turn to become actionable — the accept-gate itself is untouched.
Accessibility. #chat-log carries role="log"; the streaming reply is
revealed on it with aria-live="off" (so per-chunk typing never thrashes a
screen reader), and each COMPLETED reply is announced once via a dedicated
#chat-announcer polite live region (UX-3). aria-busy toggles with the
in-flight request; #chat-input has an explicit <label>; Enter sends,
Shift+Enter inserts a newline; input + Send disable for the request
(setBusy()). A proposal decision moves focus to its result line and, on
failure, surfaces the error on the card with Accept/Reject left retryable
(TECH-2/UX-4). The pane tabs implement the full WAI-ARIA Tabs pattern —
role="tab"/"tabpanel", aria-controls, roving tabindex, and
Arrow/Home/End navigation (UX-1); the command palette is a combobox with
arrow-key roving and aria-activedescendant (UX-5).
Preview on hover/focus. An interactive proposal card calls
range.select() (via Excel.run) on mouseenter/focus — a selection
change only, never a write — so the analyst sees the exact target range
highlighted by Excel’s own selection UI before deciding Accept/Reject. A stale
or renamed-sheet address fails silently (the preview is a convenience, not a
governance step); Accept/Reject still work regardless.
POST /command returns {command_id, label, group, arity, selection, spec,
status:"spec"}. The spec is the declarative object both renderers consume;
its authoritative schema lives in the scripts/format_spec.py module docstring.
Shape:
{
"renderer": "office_js",
"command_id": "color.autocolor",
"arity": "range", // range | cell | sheet
"kwargs": { }, // e.g. {"currency": "USD"} for number.accounting
"ops": [ // ordered declarative operations
// {"kind":"font_color","color":"#0000FF"}
// {"kind":"font_name","name":"Calibri"}
// {"kind":"number_format","format":"<excel code>"}
// {"kind":"fill","color":"#FFF3E0"}
// {"kind":"border","edge":"top","style":"thin"|"double"}
// {"kind":"autocolor","buckets":{"input":"#..","formula":"#..","link":"#..","external":"#.."}}
// {"kind":"decimals_step","step":1,"ladder":[...],"default":"<code>"}
// {"kind":"font_size_step","step":1,"min":6,"max":18,"default":9}
// {"kind":"monogram","live":false} // sheet-level: closed-file (CLI) only
]
}
Colors are #RRGGBB (the Office.js / CSS form); the openpyxl renderer converts
to full-alpha FFRRGGBB. monogram (a sheet-level image embed) has no live
Office.js equivalent — the add-in no-ops it with status unsupported_live; use
the CLI reformat path for it.
cd addin
npm ci
npm run audit:deps # npm audit (moderate+) + resolved-tree validity
npm run build # production bundle → dist/
npm run typecheck # tsc --noEmit (TypeScript strict)
npm run validate # office-addin-manifest validate manifest.xml
npx office-addin-dev-certs install.npm run dev-server (serves dist/ on
https://localhost:3000, the host the manifest points at).http://127.0.0.1:8765 is live (the adapter that imports
scripts.addin_commands).npm run start (uses office-addin-debugging), or
Excel → Insert → Add-ins → My Add-ins → Upload My Add-in → pick
manifest.xml.manifest.xml.Replace the placeholder GUID and the localhost host/icon URLs before any
non-dev publish. For firm-wide rollout at Tempest, follow
DEPLOYMENT.md — the firm HTTPS origin is GitHub Pages at
https://typhoon.tempestcapital.com/typhoon/ (custom-domain workflow
addin-pages), then Centralized Deployment via the Microsoft 365 admin center,
plus the Entra-authenticated Azure bridge; typhoon serve is engineer-only.
The Python half (scripts/addin_commands.py, tests/test_addin_commands.py)
is fully covered by this repo’s CI: ruff, mypy --strict, and a pytest that
asserts the formatting-only / no-value-mutation invariant and the KeyError
gate on a scratch openpyxl worksheet.
The dedicated sso-production-gate exercises TypeScript tests/typecheck,
manifest generation/validation, the exact cloud webpack artifact, Bicep,
ShellCheck/actionlint, hashed dependency drift, non-root container startup,
SBOM and high-severity image scanning on pull requests and main.
The single unverifiable step is sideloading into Excel. The in-Excel runtime (Office.js host, the ribbon ExecuteFunction bindings, the task-pane render, and the live round-trip to the bridge) cannot be driven by automated tests in this environment; it requires a human to sideload the manifest into a real Excel host and click through. That manual step is the sole gap between “CI-green” and “verified working in Excel,” and it is called out here so no one assumes the in-Excel behavior is machine-verified.
The Python half of the P3-2 spec contract is machine-verified:
tests/test_format_spec.py locks the registry⇄spec parity, the KeyError
governance gate, and — the acceptance gate — that the CLOSED-file AutoColor spec
apply is byte-identical to the pre-existing autocolor_workbook writer.
Classifier parity (FMG-1). The live-selection AutoColor classifier
(src/classifier.ts) and the Python classifier now consume ONE shared golden
fixture, fixtures/classify_vectors.json. npm test (tsc -p tsconfig.test.json
&& node --test dist-test) runs the TypeScript side over the fixture; the Python
test runs the same vectors. A convention change that updates only one side fails
CI — the two can no longer drift silently. Ribbon icons (assets/icon-*.png)
are a complete set matching every manifest resid 1:1.
There is no Office.js unit-test harness in this project, so the LIVE renderer
(bridge.ts applySpec) is verified by hand after sideloading:
typhoon serve; inject the token/base from the handoff file into the
taskpane (§4). Confirm the connection chip is green.A1, =A1*2 in A2, =Sheet2!A1 in
A3, a text label in A4. Select A1:A4.A1 turns blue (input), A2 black
(formula), A3 green (cross-sheet link), A4 unchanged (label).typhoon serve, click a ribbon button — the call
fails cleanly (no partial format); the chip reads disconnected.