How the citation rendering stack works
From a retrieval hit to a verify button next to a sentence, in four components. The plumbing behind every cited claim PursuitAgent ships, and why we render the source inline instead of in a footnote.
A citation in PursuitAgent is not a footnote. It is a pointer the reviewer can click to see the exact KB block a sentence was drafted from, with the matched span highlighted. The button sits inline next to the sentence, not at the end of the document.
We’ve written before about why grounding has to be enforced at draft time, not at review time (the Pledge post). This is the companion post on the rendering side. From the moment the retrieval engine returns a block to the moment a reviewer clicks “verify,” four components are in play. This post walks through each.
Component 1 — The block pointer
When the drafting engine emits a sentence, it emits it with a structured pointer to the source. The pointer is not a URL. URLs go stale, redirect, or point to a different version of the document by the time a reviewer clicks. The pointer is a tuple:
type BlockPointer = {
blockId: string; // KB block UUID
blockVersion: string; // content hash, immutable
documentId: string; // origin doc
pageRef: PageRef; // page + bbox if PDF, paragraph index if text
spanStart: number; // char offset within block
spanEnd: number; // char offset within block
};
The pointer is stored on the drafted sentence as a sibling field, not embedded in the sentence text. Storing it as a sibling has two consequences. First, the reviewer’s eye doesn’t trip over inline brackets — the citation is rendered as UI, not as text. Second, when the sentence is exported to Word or a portal, the citation is rendered using the export target’s native footnote style instead of bleeding our internal pointer format into the customer’s deliverable.
The block version is a content hash. If the customer edits the block in the KB after drafting, the pointer still resolves to the version that was used at draft time. The verifier won’t surprise a reviewer with a different sentence than the drafter saw.
Component 2 — The retrieval-to-pointer bridge
The drafting engine and the retrieval engine talk to each other through a typed result envelope. We’ve iterated on this envelope three times in the past year — the current shape is below.
type RetrievalHit = {
blockId: string;
blockVersion: string;
score: number; // 0..1, hybrid score
bm25Score: number;
denseScore: number;
matchedSpans: Span[]; // up to 3 best-matching spans
blockText: string;
documentId: string;
pageRef: PageRef;
};
The matched spans are the bridge. When the drafting model is given a retrieved block, it is also given the spans the retriever thinks were the highest-signal portion of the block for this query. The model is instructed to draft from the block as a whole but to bias toward the matched spans when the block contains material unrelated to the question.
Once the model emits a sentence, the verifier (described in the Pledge post) confirms entailment. The verifier returns the narrowest span in the block that entails the drafted sentence — not necessarily the same span the retriever surfaced. That narrowest span becomes the pointer’s spanStart / spanEnd. The reviewer’s verify button highlights exactly the text that supports the claim, not the broader paragraph it sat in.
Component 3 — The inline verify button
The button is small. It sits to the right of every drafted sentence with a colored dot indicating verification status: green for entailed, yellow for partial, red for ungrounded. Hovering reveals the source document name and page. Clicking opens a side panel that shows the block with the matched span highlighted.
We chose the inline placement deliberately. The conventional rendering — superscript numbers that link to a footnote section — has a known UX failure mode: nobody clicks the footnotes. We tested it in early prototypes. Reviewers read the document linearly, accept claims that look plausible, and only check footnotes after the fact when something feels off. By that point, fabricated claims have already passed review.
Inline verify is more effort to render but it changes the review behavior. Reviewers click. The Stanford HAI study on legal RAG observed something similar — reviewers of cited outputs accept many fabricated claims when the citations are post-hoc, because the cognitive cost of clicking through is too high to do for every sentence. Putting the dot inline collapses that cost.
<Sentence>
<span>{drafted.text}</span>
<VerifyButton
pointer={drafted.pointer}
status={drafted.verifierStatus}
onClick={() => openSourcePanel(drafted.pointer)}
/>
</Sentence>
The component itself is unremarkable. The discipline is that every sentence the system displays passes through it. There is no rendering path in the product that emits a drafted sentence without a verify button next to it. If the engine couldn’t ground the sentence, the sentence isn’t displayed — a refusal placeholder is.
Component 4 — The source panel
When the reviewer clicks, a side panel opens with the source. For text blocks, the panel shows the full block with the matched span highlighted in yellow. For PDF blocks, the panel renders the original page with a bounding box drawn on the matched region. The reviewer sees the document layout — table cells, signed cover letters, redaction stamps — not just the extracted text.
Rendering the original PDF page is more expensive than rendering the extracted text, and we considered cutting it. We left it in because reviewers, especially compliance reviewers, distrust extracted text. They want to see the source as the buyer’s evaluators would see it. A SOC 2 attestation that says “Type II” in extracted text but reads “Type I” in the rendered original is the kind of failure that costs you a renewal. The bounding box catches it.
The panel also exposes the block’s lineage: when it was added to the KB, who owns it, when it was last verified, and which previous proposals cited it. A reviewer who notices that a block hasn’t been touched in 14 months can flag it for refresh. We surface freshness signals in the review UI (changelog post coming up) and flag stale blocks at draft time, not just at review time.
Where this still falls over
Three known weaknesses.
Multi-block citations. A drafted sentence sometimes legitimately rests on two blocks — one for a numeric claim, one for a qualifier. The current pointer schema is single-block. We compose the verify behavior when the verifier finds entailment requires a second block, but the panel UI doesn’t render two sources side by side cleanly yet.
Span boundaries on tables. Tables extracted from PDFs are stored as tab-delimited blocks. The verifier returns a character span, not a cell range. Highlighting works but reads awkwardly when the matched span crosses cell boundaries. We’re refactoring the table block representation to native cell pointers in the next sprint.
Round-trip to Word export. When we export a drafted proposal to a Word document, citations are rendered as Word comments tied to the sentence. Word’s comment model doesn’t preserve our matched-span detail — it only points at the block. The reviewer in Word sees less than the reviewer in the PursuitAgent UI. A workaround using Word’s reference feature is in flight but not shipped.
The short version
A citation is a pointer plus a UI affordance plus a panel that shows the source as the document’s reviewer would see it. We render it inline because reviewers don’t click footnotes. Every sentence that ships through the product passes a verify button on the way out.
The citation rendering stack is small — four components, none of them individually clever. The work was getting them composable enough that no draft path bypasses the chain.