Half of what the agent knew was uncitable
We shipped a product whose pitch is an agent that retrieves, browses, remembers and calls your systems. Then an answer built on a web page, an MCP server, or something the user said last week came back with citations: [], claims: [], attribution: "none" — and the [2] in the reply really was the model's invention.

There were two arrays. citations held document passages. Everything else went to a sources list that no claim could point at.
The reasoning behind that was defensible and wrong. A citation, we said, means a passage with a document_id and a chunk_index you can open. An MCP result has neither. So it is not a citation.
What that missed: what was uncheckable was never the result. A tool call sits in the transcript and is at least as followable as a 280-character quote. What was missing was that nothing had numbered it.
One register now
Everything the turn was shown is numbered, whatever it came from. A document passage, a web result, a page the agent opened, something it remembered, a wiki page, a result from one of your systems, a port it exposed. One list, one sequence, and the agent closes a grounded sentence with the number it used.
res.content; // "There are 5 free days at destination [2]."
res.claims; // [{ text: "There are 5 free days…", start, end, citations: [2] }]
res.citations[1]; // { marker: 2, kind: "document", title, quote, used: true, page, score }
res.attribution; // "per-claim" | "retrieval-only" | "none"kind is document · web · memory · wiki · tool · preview · file. Render title, quote and url; branch on kind only for the extras you want — a page number for a document, the tool's name for a result, "you told me this" for a memory, the path for a file.
One decision in there worth explaining: kind describes the evidence, not the tool. A page the agent fetched with browse_url and one it drove to with the full browser are both web, because what the reader checks is the same URL either way. Naming the mechanism would have leaked our implementation into your UI for no benefit to anyone reading the answer.
What is citable, and what is not
The rule is the one that starts every conversation about this — a citation is something anyone can go and check — applied literally.
| a file the agent read in its sandbox | kind: "file", title is the path. Citable: you can ask for that file, and the agent can send it |
| a page it browsed or fetched | kind: "web", with the URL |
| command output | not citable. It is gone once it scrolls past, so there is nothing for a reader to look at — and a twenty-step loop would bury the register in noise |
The interesting consequence is what the agent is told to do about that last row. If a computation matters, write it to a file and read the file back — which makes it citable under the first rule rather than needing a special case.
That matters most for the answers most worth checking. An audit whose number comes out of arithmetic done in a sandbox can now point at the file it computed from, where before it could only say "according to /workspace/fact.txt" in prose. A parenthetical and a pill look the same to a reader, and only one of them is checkable.
Check attribution before you draw anything
retrieval-only means the model attributed nothing this turn. You have what was looked at and no per-sentence mapping, which is a genuinely different thing to render — a source list, not footnotes.
used on each entry says which of the register did any work. A turn can retrieve eight passages and use two.
Rendering the pills without offset arithmetic
import { renderCited } from "@oberik/sdk";
{renderCited(res).map((seg, i) => (
<span key={i}>
{seg.text}
{seg.citations.map((c) => <Pill key={c.marker} citation={c} />)}
</span>
))}Every segment comes back in order, attributed or not, and concatenating text reproduces content exactly. So an unattributed answer is one segment with no pills and needs no special case in your component — which is the property that stops a rendering layer accumulating branches.
A marker the model invented resolves to nothing and is dropped, rather than being rendered as a pill the reader would click into nothing.
If you would rather place footnotes yourself and keep [2] out of the visible prose, set the project's citation markers to stripped and work from claims[].start / end.
Groundedness covers the whole register
The output guardrail judges the answer against what the turn's tools returned as well as against retrieved passages. So an answer built from MCP data is checked like any other. That falls straight out of having one register — when everything the turn was shown is in one place, "is this answer supported by what it saw" is a question you can actually ask.
What documents still give you that MCP does not
Two things, and they are why you would upload something rather than wire it up as a tool:
A page and a chunk to open. A citation to an MCP result points at a tool call. A citation to a document points at a specific span on a specific page, which is what a person actually wants when they are checking a number they are about to rely on.
Staleness detection. When a document changes, the wiki pages built from it are flagged. A tool result has no such lineage — it was true when the tool answered and nothing tracks it afterwards.
Wire it up as a tool when the data is live and the question is "what is it right now". Upload it as documents when the question is "what does our policy say", and somebody may have to prove the answer six months later.
