EXTERNAL SERVICESEvery outside service the engine calls: Anthropic and Voyage for the AI, SEC EDGAR and hosted web search for research, Groq and Resend for voice and auth email.

EXTERNAL SERVICES

lib/llm/client.ts · lib/ingest/embedder.ts · lib/research/ · lib/email/every key optional
except two

Everything the engine calls that it does not own: two AI providers - Anthropic's models for the reading and writing, Voyage for embeddings - two research sources - SEC EDGAR and hosted web search - and two utilities, Groq Whisper for voice notes, Resend for auth email.

A colored line leaving this hall means that district's code makes network calls to that provider, and every landing is captioned with the provider's name. Claude wires into ingestion (OCR, summaries, chunk prefixes), analysis, research, and memo generation with its faithfulness judge. Retrieval only ever calls Voyage - the searching itself is embeddings and Postgres - and storage calls nothing at all. Groq and Resend draw no line: each serves one workspace feature, named on its card.

Only two can stop the show: no Voyage, nothing embeds; no Anthropic, nothing is analyzed. The rest degrade gracefully - a dead EDGAR or web search costs a memo its enrichment, never its run.

VOYAGE AI

The embedding service: turns text into vectors so meaning can be searched, not just words. Every chunk INGESTION produces and every query RETRIEVAL runs passes through here.

voyage-3 · 1024-dim float32 · fetch, no SDK
batches: 96 items AND 400KB · 429 retries ×4

ANTHROPIC API

The models that read and write everything, tiered by job: Fable 5 reasons and drafts, Opus 4.8 extracts, Sonnet 5 researches, chats and judges faithfulness, Haiku 4.5 handles OCR and issuer identification.

tiers + budgets in lib/config/models.ts, per-deal overrides
prompt cache at two TTLs: 1h stable prefix · 5m retrieval

SEC EDGAR

The SEC's free public filings database. Confirms whether the issuer is a public company and pulls its recent filings - annual and quarterly reports, event disclosures - as research evidence.

findIssuer → CIK/ticker · fetchSubmissions → filings
keyless · fair-access UA · no retry: callers degrade to null

WEB SEARCH + FETCH

Anthropic's server-side research tools, not a scraper of ours: mid-call, Claude decides what to look up, Anthropic runs the query against Brave's search index, and web_fetch pulls the full pages the model picks - no search key or crawler of ours to maintain.

server tools: web_search ×20 · web_fetch ×15 per run
results land as model text · sources graded, not trusted

GROQ WHISPER

Speech-to-text behind exactly one feature: the hold-to-talk mic button in the memo workspace's refinement chat. A dictated note is posted to /api/transcribe, transcribed by Whisper, and enters the same chat-refine loop as a typed one.

POST /api/transcribe → whisper-large-v3 → {text}
optional: without the key only the mic button fails

RESEND

Transactional email for the auth flow: password-reset links go out through here, from a DKIM/SPF-verified address.

links built off appBaseUrl() · anti-enumeration replies
optional: without the key only the reset email fails
APPLICATIONThe Next.js app analysts actually use: it authenticates every request, runs the deal workspace, drives the pipeline stage by stage, and gates every advance on a human. The test lab rides inside it.

APPLICATION

app/ · components/ · proxy.ts · lib/workflows/humans gate every advance

The human side of the system and the only part anyone ever touches: the Next.js app that authenticates every request at one front door, renders the deal workspace, and turns analyst gestures - upload, approve, refine, export - into pipeline work.

It sits above the whole factory floor because it drives all of it. The durable pipeline conductor lives here and calls the districts below in stage order - through analysis automatically, everything after only once a human approves the tear sheet - while the workspace polls deal status out of STORAGE to stay current and renders every citation superscript as a click-through to its exact source page or cell. Nothing below ships without an approval that happens up here.

The test lab rides inside this hall because it pins the same code: 58 test files that run with no database and no API keys, plus the citation-faithfulness eval that also closes every live pipeline run.

proxy.ts middleware

The gatekeeper in front of everything: every request to any route is authenticated here first, so no page or API handler ever has to remember to check.

JWT (jose HS256) · 3 session shapes:
user{id,role} · legacy shared-password · demo
demo: non-GET ⇒ 403 read-only, scoped to the demo deal
admin routes live-check user status in the DB

Durable pipeline · the conductor

The machinery behind the workspace's buttons: runs the eight stages across the districts below in order - through analysis automatically, the rest only after tear-sheet approval, ending with the citation-faithfulness check on both drafts - and survives crashes and deploys mid-run.

800s budget per stage · crash retry · resume from
the failed step · stop is cooperative, DB-backed

DealWorkspace · 6-step flow

The operator console for the whole pipeline: upload → process → analyze → preview → edit → export, one panel per step, kept current by polling the deal's status.

GET /api/deals/{id}/status
600ms while generating · 1500ms idle
optimistic pendingKind + 15s safety net

Citation overlay

Where the citation promise is kept for the reader: every superscript in a memo or tear sheet opens its actual source - PDFs jump to the cited page, spreadsheets highlight the cited cell.

buildFootnoteIndex → sup 1…N · keys 1–9 jump
viewers: pdf #page=N · xlsx cell highlight · text · web

Chat + voice

The refinement surface: iterate on any draft by chat, or hold-to-talk and let transcription drop the note into the same loop.

MediaRecorder → POST /api/transcribe
→ whisper-large-v3 → transcript into the loop

CoverageGate

The approve-time conscience: lists every claim the linter found uncited before an analyst signs off. Advisory, never blocking - but overriding requires a written reason that gets stored.

lintCoverage findings → jump-links into the draft
"Approve anyway" ⇒ typed reason stored with the approval

Admin + auth

Account plumbing: signups held pending until an admin approves, email domain allowlisted, rate-limited logins, hashed single-use reset tokens mailed out via Resend.

users: pending → active · first admin by configured email
logins rate-limited per account · resets single-use

/demo

The public link mints a 24-hour read-only session and lands straight in the real AMC deal - nothing mocked, every mutation rejected at the proxy.

GET /demo → 24h demo JWT → the real AMC deal
proxy: any non-GET ⇒ 403 · scoped to the demo deal
TESTS & EVALS · tests/ · lib/eval/ · 58 test files

Vitest · DB-free

Units + integration with no database or API keys - the lazy DB proxy and mocked runStep make it possible. Parser fixtures are synthetic, generated by checked-in scripts.

Playwright e2e

Auth & deal-creation smoke · citation click-through to source viewers · folder flows, against the running app.

Citation-faithfulness eval

An LLM judge grades every claim against its cited chunk - mis-citation and fabrication measured separately. The same eval core runs offline against planted defects and live as the pipeline's final stage (MEMO GENERATION).

judge: Sonnet · temp 0 · forced verdict tool
coverage / faithfulness / hallucination scores
planted-defect fixtures · CI gate exits non-zero
live: final pipeline stage → artifacts.eval_report
UPLOADTakes in a deal's raw documents from the analyst, fingerprints and dedupes them, and stores them for the pipeline.

UPLOAD

app/ · components/files/in: a messy folder
out: stored, deduped files

THE FLOW · 1 OF 6A deal arrives as a messy data room - nested folders, cryptic filenames, a dozen formats. Upload stores every unique file exactly once: SHA-256 fingerprinted, deduplicated per deal, content untouched. No document is read at this stage; parsing starts in INGESTION.

The browser streams each file directly to blob storage, bypassing the 4.5MB serverless request limit; the app server never handles the bytes, only records the receipt in STORAGE. Duplicates are caught by hash before any parsing spend.

The data room · the raw input

Where everything starts: deal documents as they actually arrive from a banker - nested folders, cryptic names, mixed formats. Everything downstream exists to turn this pile into a defensible memo.

pdfxlsx/xlsmdocxpptxmsgemlrtfcsv/md/txtpng/jpg/heic/tiff
demo corpus: 79 real SEC filings (AMC Entertainment)
built by scripts/fetch-demo-dataset.ts - 10-Ks, 10-Qs, 8-Ks,
credit agreement + indenture exhibits, XBRL workbooks

UploadDropzone

The drag-and-drop surface an analyst actually touches. Accepts whole folders, streams every file straight to cloud storage, and fingerprints each one so duplicates are caught before any money is spent parsing them. Hands the stored files to INGESTION.

upload(File[]) → Vercel Blob (browser-direct)
webkitGetAsEntry() folder recursion
ignores .DS_Store · ._* · Thumbs.db · .git · node_modules
16 workersretry ×1 +400mssha-256 clientskip hash >100MB

POST /api/upload/token

The front door for every browser upload: it grants the browser permission to store a file, then records the finished upload in the database. Exists so big files never pass through the app server at all.

handleUpload → onBeforeGenerateToken → onUploadCompleted
token leg: session · callback leg: x-vercel-signature
browser streams straight to Blob - bypasses the 4.5MB
serverless body limit; blob deleted if the DB write fails

POST /api/upload-direct

The same front door for scripts instead of browsers - used by the demo-dataset fetcher and for workbooks too large to hash client-side. Same dedup, same rollback guarantees.

x-deal-id · x-sha256 · x-pathname → put() → createFile()
415 on audio/video · same rollback contract
dedup UNIQUE(dealId,sha256)partial index

Hands the stored, deduped files to INGESTION - the pipeline's first automatic stage starts parsing them without another human touch.

INGESTIONParses every stored file into cited, embedded chunks - 23 formats, OCR for scans - ready to search.

INGESTION

lib/ingest/in: Buffer
out: ChunkInsert[]

THE FLOW · 2 OF 6Three separate jobs run per file, and they are easy to conflate: the text is chunked and embedded for search; every chunk gets a locating prefix; and the whole file gets a summary. Prefixes serve retrieval; the summaries, stitched across all files, become the deal manifest that rides in every drafting call's system prompt. Two independent model passes - a prefix is not a summary.

Chunking. Parsers (23 formats, 11 of them) turn each file into located pages or sheets, and each page is cut into chunks of at most roughly 1,500 tokens. A chunk never spans two pages - the page or cell location survives as the citation anchor a reader clicks later. Splits fall on paragraph boundaries, then sentences; the 200-token overlap between neighboring chunks kicks in only where text has no natural boundary and must be word-split. Voyage embeds each chunk together with its prefix as a 1024-number vector into STORAGE.

Prefixes. A model writes 2-3 sentences locating each chunk in its document, 10 chunks per call, with the document riding along in rolling prompt-cached sections of roughly 100K tokens (capped at 100 sections). Sections past the first are prepended with the document's first ~2K tokens so the model still knows which file it is inside. Cost stays linear with document size; caching keeps it low.

Summaries. An independent pass reads the file in ~50K-token windows, one model call per window, merged into the file's one-paragraph summary. A file needing more than 64 windows widens them stepwise up to ~100K tokens each; past roughly 6.4M tokens even that overflows, and the pass keeps 64 evenly spaced windows instead - coverage is no longer complete, and the summary says so in a "(summarized N of M sections)" note. Compression runs 500-to-1,000 to one: a 50K-token filing collapses to a summary of about 100 tokens, and the ratio improves as files grow.

Parser registry · 23 formats, 11 parsers

The first stop for every uploaded file: picks the right parser for its format, and every parser returns the same shape - so everything downstream never has to care what format a document arrived in.

getParser(mime) → (Buffer, name) → ParseResult
ParseResult { pages: {location, text, structural?}[],
              fileDate?, ocrUsage? }
unpdfSheetJSmammothjszip+xmlmsgreadermailparserrtf-parserheic-convert

parsePdf + ocrPdf

Reads PDFs - the bulk of any data room. Digital PDFs give up their text layer verbatim; only a scanned, textless PDF falls back to Claude-vision OCR. Page numbers captured here become the citation anchors readers click later.

unpdf per page → all empty ⇒ Haiku document block
forced tool emit_pages → p.N pages, metadata.ocr
32 MiB cap100 pages120s timeout16k max_tokens

parseExcel

Reads the financial models. Each sheet yields both its cell values and a structural view - headers, formulas, named ranges - so retrieval can find how a model computes, not just its numbers.

sheet_to_csv + buildStructuralView(ws)
location "sheet:Name" - the citation anchor for cells
SheetJS, not ExcelJSformulas ≤200dense mode

chunkPage

Splits parsed pages into retrieval-sized pieces - the unit everything downstream searches, cites, and quotes. Splits on paragraphs first, then sentences, then words, with overlap so no fact is lost on a boundary.

chunkPage(ChunkInput) → ChunkOutput[]
+ one #structural chunk per spreadsheet page
1500 tok target200 overlapgpt-tokenizer

generateDocumentSummary

Writes a one-paragraph summary of each document at ingest - the only time any file is read in full. The summaries become the deal manifest: how every later model call "sees" the whole data room at once.

Sonnet 5 · max_tokens 400 · ~50K-tok windows ×≤64
>64: widen to ~100K tok · >~6.4M tok: sample 64 evenly
compression ≈500-1,000:1 (50K tok → ~100-tok summary)

generateContextualPrefixes

Gives each chunk a 2–3 sentence prefix locating it in its document before embedding - a bare table ripped from page 84 means nothing on its own. This is what makes vector search reliable on dense filings.

rolling ~100K-tok sections, each prompt-cached; batches of 10
forced tool emit_chunk_prefixes → prefixes[]
batch 10concurrency 6cache 5m ×1.25 · 1h ×2

embed · Voyage

Turns every prefixed chunk into a 1024-number vector - the coordinates STORAGE indexes and RETRIEVAL searches. Batched against two caps because financial text tokenizes heavy.

embed(texts) → number[1024][]
96 items/batch400KB/batch429 ×4 backoff

ingestFile · the pipeline itself

The conveyor that runs each file through this whole district: parse → chunk → summarize → prefix → embed → store into pgvector, with every token of model spend accounted back onto the file's row.

insertChunks ×100/batch → pgvector
setParseStatus parsing→parsed | failed (+error)

Writes cited chunks with embeddings into STORAGE - each one carrying the page or cell anchor a memo reader will click months later.

STORAGEPostgres holds everything the engine knows - text, vectors, deal state - and every other section reads and writes here.

STORAGE

lib/db/ · drizzle/13 tables
+ blob store

THE FLOW · UNDER EVERY STAGEPostgres holds everything: chunks and their vectors in one table, deal state in another, and every analysis and memo ever generated as versioned rows in the artifacts table. The most important row in the system is the latest analysis artifact - its structured JSON column holds the deal record, the object every stage after analysis passes along.

The deal record's life. ANALYSIS writes it when the five extractors' outputs merge (about two dozen sections: entities, capital structure, financials, covenants, risks, recommendation, and so on). The tear sheet is that record rendered on screen. The planner reads it to write research questions. RESEARCH appends its findings into the record's research field, on the same row. Both memo prompts then embed the record whole, and the memo retrieval queries fill their blanks from it.

Nothing is edited in place - regenerating any artifact writes a new numbered version, so history is walkable and revert is safe. Raw file bytes live in blob storage, not here. The DB client is a lazy proxy that connects only on first real use, which is why the unit-test suite runs with no database.

chunks · the vector store

Where INGESTION's output lives and what RETRIEVAL searches: every chunk's text, its contextual prefix, its citation location, and its 1024-number embedding under a cosine index.

embedding vector(1024) · ivfflat vector_cosine_ops
text · summary(prefix) · location · fileDate · metadata

artifacts

Every analysis and memo the system ever produced, kept as immutable numbered versions - nothing is edited in place, so history is always walkable and revert is always safe.

kind: analysis | analysis_reasoning | internal_memo | external_memo
UNIQUE(dealId, kind, version) · structured jsonb · schemaVariant
memo rows add eval_report jsonb - the faithfulness verdict

deals · files · citations · chat

The working state of every deal: its status machine and pipeline flags, each file's parse state and token spend, and the citations table mapping every memo footnote back to its exact source chunk.

deals: status ×9 · stepConfigs · pipelineState · publicProfile
files: parseStatus + per-file token usage · UNIQUE(dealId,sha256)
citations: marker F#/W#/E# → chunk (web/SEC rows keep chunk NULL)

supporting tables

Accounts and workspace plumbing: users (pending → active, admin bootstrap by email), folders (recursive tree with a cycle guard), collaborators, reference images, output preferences, login rate-limiting, reset tokens.

Lazy DB proxy

The trick that lets the whole unit test suite and a clean CI build run with no database at all: the client only connects on first real use, so merely importing it never crashes.

export const db = new Proxy(...) - resolves on first
property access; importing without DATABASE_URL never
crashes ⇒ the whole unit suite runs with no database

Vercel Blob store

The byte side of storage: original uploads, finished exports, and per-deal reference images. Postgres holds text, vectors and state; Blob holds raw files; the server's own disk holds nothing.

Serves chunks and their vectors to RETRIEVAL's searches - while every other district reads and writes its state here too.

RETRIEVALAnswers every model call's question - what do the documents say - with ranked, cited evidence.

RETRIEVAL

lib/llm/retrieval.tsin: queries
out: ScoredChunk[]

THE FLOW · FEEDS EVERY STAGEA retrieval query is a short search sentence: the vector store answers with the chunks closest in meaning, and code pastes them into a prompt - the model never sees the query, only the evidence it returned. Three different things get called "queries" in this pipeline: these fixed template searches, the planner's written questions (which are never searched directly), and the researcher's live tool searches.

Who queries what. Analysis reasoning runs 8 fixed templates, keeping 20 chunks each. Each extraction desk re-runs its own fixed set - three templates for the company desk, two for structure, financials, and risk, one for recommendation. Each memo runs 8 templates whose blanks are filled from the deal record, keeping 30 per query. Chat refinement embeds each user message as a fresh query. The researcher's document tool searches on demand, 6 chunks per call. The faithfulness judge runs uncited memo claims as queries of their own.

Ranking blends similarity with recency, so a later amendment outranks the clause it superseded, and results are capped per source file (6 for analysis, 6 internal / 10 external for memos) so one 10-K cannot crowd out the credit agreement. The F1, F2… numbers a memo cites are assigned here, at the moment evidence is packaged into a prompt.

Query fan-out

How evidence gets pulled from STORAGE for every model call: eight parallel searches - deal overview, financials, capital structure, company, industry, management, risks, comps - because one query can't span eight topics without retrieving mush.

"{dealName} - {template}" ×8 → embedQueries (Voyage)
→ searchChunks(limit 80) per query, in parallel
verbatim query lists → ANALYSIS + MEMO GENERATION tiles
8 reasoning8 memo1–3 per deskchat topK 35640 cand → ≤160 in-prompt

scoreCandidates · blended ranking

Ranks what the searches return before it reaches any prompt: similarity blended with recency, so a later amendment outranks the superseded clause it replaced.

score = cosine × (1 + e^(−ageDays/365)) + mimeBoost
BSL deals: xlsx +0.15 (the model is primary evidence)
null fileDate ⇒ age = halfLife

dedupeByFile · per-file caps

Caps chunks per source file so one 10-K can't crowd out the credit agreement; applied per-query and again post-merge.

≤6 analysis≤6 internal≤10 external

renderDocBlock · F# is born here

Packages the winning chunks into the prompt and hands each one its citation number. The F1, F2… assigned here is the same number the model cites, the validator checks, and the reader clicks.

<doc id="F1" location="p.4" date="2024-03-12">
Context: {contextual prefix}
{chunk text}</doc>

Passes ranked, cited evidence into the ANALYSIS agent's prompt - already numbered F1, F2… so every claim can point home.

ANALYSISTakes the curated deal data from ingestion and storage and runs a two-pass credit analysis: reason first, extract second.

ANALYSIS

lib/memo/analysis-generator.ts · orchestratorin: chunks
out: Analysis (~40 fields)

THE FLOW · 3 OF 6Two sub-steps. A writer (thinking on) reads the deal manifest plus chunks from eight fixed queries and writes one long free-form assessment. Five extractors (forced tool use, thinking off) then turn that prose into the deal record: a JSON object of about two dozen typed sections. The split exists because the API rejects thinking combined with forced tool use - one call cannot do both.

Why the extractors re-query. Retrieved chunks live only inside the prompt that requested them; the extractors are separate calls that see the writer's output, never its evidence. So four run in parallel - company, structure, financials, risk - each reading the prose plus its own narrow retrieval, and each filling only its own fields with facts: names, numbers, dates, ratings. The recommendation extractor runs last, fed the other four's output plus the prose, so the go/no-go sees the whole picture. Code merges the five into one record; a failed desk falls back to schema defaults rather than sinking the run.

The automatic run stops here on purpose: the TEAR SHEET GATE at the foot of this district waits for a human before research and the memos spend real money and reviewer attention.

Pass 1 · reasoning

The thinking half of the analysis: the frontier model reads the deal manifest plus RETRIEVAL's evidence and writes free-form, citation-anchored analytical prose. That prose feeds the extraction desks; nothing after them reads it.

runStep · Fable 5 · thinking always-on
8 fixed queries (verbatim list below) → [F#:loc]-cited prose

Prompt-cache layout

The economics of the whole district: four system blocks arranged so the expensive stable prefix is written to the prompt cache once and read cheaply by every parallel desk call.

[0] base prompt        cache 1h
[1] deal ctx+manifest  cache 1h
[2] reference images   cache 1h
[3] retrieved docs     cache 5m  ← only this varies
⇒ 1 cache write, 4 reads per analysis

Pass 2 · 5 extraction desks

The structured half: five desks turn pass 1's prose into ~40 typed fields. Company, structure, financials and risk run in parallel; recommendation runs last, fed the other four. Two passes because the API can't force a schema and think in the same call.

runStructuredStep · forced tool extract_analysis
API rejects thinking + forced tool_choice ⇒ desks
fall back to Opus 4.8, thinking off (mechanical work)
4 parallel + 1 seqretry 1s/3sdesk fail ⇒ Zod defaults

mergeDesks → schema.parse

Assembles the five desk outputs into the one Analysis object every later stage - tear sheet, research, memos - reads from. The desks' schemas are disjoint slices by construction, so merging can never conflict.

analysisSchema = union(bsl.v1 | pc.v1) on dealType
~40 fields · .default('') not .optional() - structured
output grammar caps 24 optional fields per schema

What it produces

Credit snapshot · capital structure · covenants · financial history · management (with red flags) · comps · scenarios · risks ×severity.

recommendation: pursue | pass | more_info
BSL adds: DM, CLO eligibility, syndication, flex
PC adds: all-in yield, OID, PIK toggle, co-invest

The fixed queries · verbatim, identical every deal

pass 1 · 8 queries, each sent as "{dealName} - {query}", keep 20 chunks each:
1 deal overview, transaction summary, purpose, sources and uses, total capitalization · 2 financial statements, revenue, EBITDA, margins, cash flow, historical performance, projections · 3 capital structure, debt tranches, pricing, leverage, loan terms, covenants, call protection · 4 company overview, business description, products, customers, geography, revenue model · 5 industry analysis, market size, competitive landscape, growth drivers, barriers to entry · 6 management team, executives, board members, backgrounds, experience · 7 risks, mitigants, collateral, guarantees, security interests, lien position · 8 comparable companies, comps, valuation, multiples, debt pricing, relative value

pass 2 · 10 queries across the 5 desks:
company: company overview, business description, products, customers, geography, revenue model · industry analysis, market size, competitive landscape, growth drivers · management team, executives, board, backgrounds, experience
structure: capital structure, debt tranches, pricing, maturity, ratings, collateral · transaction summary, purpose, sources and uses, total capitalization, closing date
financials: financial statements, revenue, EBITDA, margins, interest coverage, free cash flow, historical performance, projections · comparable companies, comps, valuation multiples, debt pricing, relative value
risk: covenants, financial ratios, thresholds, headroom, incurrence tests, debt baskets · risks, mitigants, collateral, guarantees, lien position, control provisions, call protection
recommendation: investment thesis, rationale, strengths, weaknesses, go-no-go

⌇ TEAR SHEET GATE - the pipeline parks here. An analyst reviews this analysis rendered as the tear sheet; approval releases research and the memos.

RESEARCHSends an agentic loop out to the live web and SEC EDGAR to answer what the deal documents cannot - with a search tool back into those documents to check what it finds.

RESEARCH

lib/research/in: Analysis
out: W#/E# pack

THE FLOW · 4 OF 6A planner reads the finished deal record and writes three to eight deal-specific questions - it searches nothing. The researcher answers them with real tools - web search (up to 20 uses), page fetch (up to 15), and vector search back into the deal's own documents (up to 10) - and never searches the questions verbatim: the questions are what to find out; its searches are how, each chosen live from what the last result returned.

The questions ride into the researcher's prompt as its assignment; the output format forces one answered, cited subsection per question, plus a fixed checklist: news, rating actions, peer debt pricing, SEC filings. The document tool is for cross-checking - a web claim about a maturity or covenant is checked against the credit agreement's language before it is asserted. Findings come home source-tagged (web W#, filings E#) and are written into the record's research field - the memos receive one object.

A cheap scout also runs before analysis to answer one question: is this issuer a public SEC filer? And the district is non-fatal: if the outside world is down, memos still run, just less enriched.

pre-research · runs before analysis

The scout that runs before anything expensive: is this issuer a public company? A small model reads the file summaries from INGESTION and guesses; SEC EDGAR confirms the ticker and CIK for real. The verdict - public, private, or honestly unknown - lands on the deal and steers what the tear sheet shows and what the researcher is allowed to assume. "Unknown" is a legal answer; downstream stages branch on it instead of guessing.

forced tool identify_issuer (600 tok, 12 summaries)
→ ticker/CIK → deals.publicProfile (public|private|unknown)

planner

Writes the research brief. It reads the finished ANALYSIS and asks the question retrieval can't: what do we still not know about this specific deal? Out come 3–8 pointed inquiries - each a question plus why it matters - like "what happened to the 2026 refinancing discussed in the Q3 lender call?" Deal-specific questions retrieve far better research than generic ones, and they're saved onto the analysis so the researcher works from an explicit brief, not vibes.

INQUIRIES: trailer · QUESTION/WHY stanzas · 3–8 capped
persisted onto analysis.research.dealSpecificInquiries

researcher

The stage that leaves the building: an agentic loop where the model runs live web searches and page fetches (executed on Anthropic's servers) plus EDGAR filing pulls, chasing the planner's inquiries. A client-side search_deal_documents tool points back at the deal's own vector store, so a web finding - a maturity, a covenant, a figure - is checked against the documents before it is asserted. Everything it brings back is source-tagged so its claims join the citation system like any document quote. Output follows a strict line-by-line grammar the parsers depend on - the price of keeping an agentic search loop and structured output in one call.

web_search ×20 · web_fetch ×15 · doc search ×10 · 21k tokens
trailers: SOURCES / PUBLIC_ISSUER / PUBLIC_MARKET /
CONTEXTUAL_DOMAINS · per-inquiry ### sections

EDGAR client

The SEC-side workhorse: resolves a messy issuer name to the right EDGAR entity - exchange-tagged ticker first, then cleaned-name candidates, shortest title wins so parent companies beat their financing subsidiaries - then pulls the recent filing list with exchange and industry code attached.

findIssuer(query) → {cik, ticker, name}
fetchSubmissions(cik) → filings 10-K 10-Q 8-K S-1 424B …
                      + exchange · SIC industry code

Write-back

Where research becomes evidence: the finished pack is written back onto the analysis artifact, and its web (W#) and SEC (E#) sources become first-class citation namespaces alongside document citations (F#) - validated by the same pipeline, clickable in the same overlay. A memo reader never has to care whether a claim came from the data room, the web, or a filing.

Feeds cited web + SEC findings (W#/E#) into MEMO GENERATION's drafts, alongside the data room's own evidence.

MEMO GENERATIONDrafts the tear sheet and both memos from the approved analysis, every claim carrying a live citation.

MEMO GENERATION

lib/memo/generator.ts · chat-refine.tsin: Analysis+research
out: tear sheet + memo.md

THE FLOW · 5 OF 6Both memos read the finished deal record (research now inside it), the deal manifest, and chunks from eight template queries whose blanks are filled from the record: "<company> covenant terms, headroom" becomes "Acme Corp covenant terms, headroom." The queries run at drafting time, not analysis time - the blanks need the record to exist, and re-drafts must see documents uploaded since. Internal memo first; the external is written from it.

One timeline subtlety the left-to-right layout can't show: the tear sheet is the deal record itself, rendered - it exists the moment ANALYSIS finishes, and approving it is the gate that releases research and the memos. After drafting, the faithfulness judge decomposes both memos into claims: each cited claim is graded against the exact chunk its marker points at, and each uncited claim is run as a retrieval query to check whether anything in the corpus supports it - the verdict is advisory and stored on the memo artifact. A style-and-coverage linter, a citation validator that strips markers the model invented, and the chat-and-voice refinement loop round out the floor; every draft and every refinement is an immutable new version in STORAGE.

Tear sheet · the first deliverable

The one-screen read of the whole analysis - 16 sections plus a live forecast engine, every figure clickable back to its source page or cell. This is what the analyst approves at the first human gate, and approval is what unlocks research and the memos below.

16 sections · nav + scroll-spy + body from ONE
descriptor array on shared has*() predicates
forecast: 8 assumptions → leverage/FCF/coverage, live
approve ⇒ post-approval stages

Internal memo

The triage memo: a few opinionated pages answering "is this deal worth real diligence time?" Built from the analysis record (research included), the deal manifest, and its own retrieved chunks. About a third the external memo's length - ≈7–8K output tokens on the recorded deal.

External memo

The full investment-bank-format credit memo for third parties - roughly 3× the internal memo's length, on the same evidence base under stricter formatting. Requires the internal memo to exist first.

≈3× the internal memo22.6k tok · ~6 min recorded

Prompt builders

Assemble each memo's prompt: deal-type × memo-type templates with the analyst's length/depth preferences and the full analysis embedded. Like every buildMessages call, the deal manifest of file summaries rides in the cached system prefix, so both memos see all the summaries plus their own retrieved chunks.

BSL/PC × internal/external builders
templates with LENGTH/DEPTH preset blocks
analysis JSON embedded · ai-writing-tells style
guide baked into the system base

runStep streamer

Streams the draft to the watching analyst - through the database, because serverless functions can't hold a socket open to the browser.

progress flushed to artifacts every 750ms
live token estimate chars/3.6 · 12 min ceiling

Style + coverage lint

The deterministic quality gate every draft passes before approval: AI-writing tells scrubbed, and any claim sitting too far from a citation flagged as uncited.

lintMemo: 20 banned words · 16 phrases · rule-of-three
lintCoverage: numbers/dates/proper nouns >240 chars
from the nearest [F/W/E#] marker → advisory gate

Citation validator

Why a hallucinated reference can never reach a reader: every citation the model writes is checked against what was actually retrieved; invented ones are stripped, survivors become database rows the UI can resolve.

extractCitations → validateCitations · F# ∪ W# ∪ E#
hallucinated sub-refs rewritten out → citations table
file name re-enters only at read: citations→chunks→files

Faithfulness check · final stage

The pipeline's last act: an LLM judge grades every claim against the exact chunk its citation points at, plus a corpus-wide sweep for claims grounded nowhere. Advisory - stored with the memo, never blocks it.

claims → Sonnet judge · temp 0 · forced verdict tool
F# rebound from citations · corpus sweep → eval_report

chat-refine loop

How drafts actually get finished: the analyst iterates by chat or voice, each turn retrieving fresh evidence while keeping the memo's existing citations resolvable. A layout guard protects the structures the PDF exporter depends on.

retrieveContext topK 35 · guards memo-page-1,
strengths-risks, sources-uses · prior [F#] unioned in

Version history

Every generation and every refinement is a new immutable version in STORAGE - revert is also a new version, so the full history stays walkable and nothing is ever lost to an edit.

The memo query templates · verbatim, blanks filled from the deal record

each sent as "{company} - {template} · {sector · sponsors · ratings · leverage}", keep 30 chunks each:
1 deal overview, transaction summary, purpose, sources and uses, total capitalization · 2 financial statements, revenue, EBITDA, margins, cash flow, historical performance, projections, addbacks · 3 capital structure, debt tranches, pricing, leverage, loan terms, covenants, call protection · 4 company overview, business description, products, customers, geography, revenue model, customer concentration · 5 industry analysis, market size, competitive landscape, growth drivers, barriers to entry, regulatory exposure · 6 management team, executives, board members, backgrounds, experience, equity alignment · 7 risks, mitigants, collateral, guarantees, security interests, lien position, cyclicality · 8 comparable companies, comps, valuation, multiples, debt pricing, relative value, peer spreads

⌇ REVIEW GATE - CoverageGate lists every uncited claim, an analyst signs off, and only the approved memo may leave for EXPORT.

EXPORTRenders the finished memo into PDF, Word, Excel or a ZIP - on demand, never stored.

EXPORT

lib/memo/exporter/out: PDF · DOCX
XLSX · ZIP

THE FLOW · 6 OF 6The approved memo renders into a typeset PDF, an editable Word document, a live Excel model, or the whole set zipped - on demand, never stored, always from the latest approved version. Nothing new is decided here: every number was already extracted, cited, and judged upstream.

This district sits behind the pipeline's second human gate, the REVIEW GATE at the foot of MEMO GENERATION to its left: nothing renders for the outside world until an analyst has approved the memo past the coverage check. It reads the approved draft out of STORAGE fresh per export, so a regenerated memo can't leave a stale PDF lying around anywhere. Most of the engineering here is print: making HTML paginate like a typeset document, keeping tables whole across page breaks, and turning citation superscripts into real footnotes in each format's native idiom.

PDF export

Turns the approved memo into the bank-quality document: branded cover, running header with the per-deal logo, citations as numbered footnotes, full bibliography - printed by headless Chromium.

@sparticuz/chromium + puppeteer-core
(Playwright's Chromium won't run on Vercel serverless)
Letter · margins 1.5in/0.95in · 60s render race
sanitizer: hand-rolled denylist (Turbopack × jsdom)

Print formatting · the hard part

Making HTML paginate like a typeset memo: page margins declared once and kept identical between the CSS and the printer config, headings never stranded at a page's bottom edge, tables and key blocks held together across page breaks, a two-column first page that survives regeneration.

@page rule MUST match page.pdf({margin}) exactly
orphans/widows 3 · page-break-after:avoid on headings
break-inside:avoid on tables, strengths-risks,
sources-uses · memo-page-1 two-column layout

DOCX export

The editable deliverable for bankers who live in Word. Citations become real Word footnotes - not superscript-styled text - so they keep behaving when the document is edited.

markers → FOOT:n sentinels →
FootnoteReferenceRun · PDF-only layout HTML stripped

XLSX export

A live financial model, not a data dump: real formulas an analyst can change and watch flow through, plus a growth × margin sensitivity grid.

ExcelJS · 8 worksheets · real formulas:
SUM totals · EBITDA-margin IF · %-of-total
sensitivity grid 7 growth × 5 margin steps
injection defuse '^[=+-@]>6× red · >4× amber

ZIP bundler

The everything bundle: all five deliverables produced in parallel, each isolated so one failure writes an honest note into the archive instead of sinking it.

safeProduce isolates each → FAILED_ARTIFACTS.txt
manifest, ZIP still ships · zlib level 9

JIT previews

Exports render on demand, never stored - so a regenerated memo can't leave a stale PDF lying around.

GET …/artifacts/{id}/preview  → PDF   max-age=3600
GET …/datapack-preview        → XLSX  max-age=30 swr=60

Ships the finished memo to its reader - PDF · Word · Excel · ZIP - rendered on demand, never stored.

github ↗ code web ↗