Cohere's Citations: How Command Returns Grounded Spans Automatically
9 min read · updated August 11, 2026
Pass documents to Cohere’s Chat API and the response carries a citations array that ties character spans of the answer back to the documents that supported them. No prompt engineering produces it, and no parsing of the model’s prose is involved.
A grounded response, in full
The request half is a normal chat call with a documents field. Each document is an object with an id and arbitrary string fields — Cohere does not impose a schema beyond the id, and the field names you choose are visible to the model:
curl https://api.cohere.com/v1/chat \
-H "Authorization: Bearer $CO_API_KEY" \
-H "content-type: application/json" \
-d '{
"model": "command-r-plus-08-2024",
"message": "When does the Utrecht store restock the Ultimate C8, and what does it cost?",
"documents": [
{"id": "inv-441", "title": "Utrecht stock", "text": "Gazelle Ultimate C8: 0 units in stock. Restock expected 19 August 2026."},
{"id": "price-12", "title": "Price list", "text": "Gazelle Ultimate C8 retails at EUR 2,499."}
]
}'The response, trimmed to the fields that matter here:
{
"text": "The Utrecht store expects to restock the Ultimate C8 on 19 August 2026. It retails at EUR 2,499.",
"citations": [
{
"start": 55,
"end": 69,
"text": "19 August 2026",
"document_ids": ["inv-441"]
},
{
"start": 86,
"end": 95,
"text": "EUR 2,499",
"document_ids": ["price-12"]
}
],
"documents": [
{"id": "inv-441", "title": "Utrecht stock", "text": "Gazelle Ultimate C8: 0 units in stock. Restock expected 19 August 2026."},
{"id": "price-12", "title": "Price list", "text": "Gazelle Ultimate C8 retails at EUR 2,499."}
],
"finish_reason": "COMPLETE"
}Three properties of that structure are worth naming. Citations are span-level, not sentence-level or answer-level, so the date and the price are attributed separately even though they share a sentence boundary in prose. document_ids is a list, because one claim can be supported by several documents. And the documents array is echoed back on the response, so a rendering layer that only ever sees the response object still has everything it needs to build a footnote.
In /v2/chat the same information arrives with more structure: each citation carries a sources array of objects with a type of document or tool, an id, and the document content inline. The type field is the useful addition — it tells you whether a span came from something you supplied or from something a tool returned mid-loop, which document_ids alone could not express.
What start and end are counted in
start and end are offsets into the text field of the response, and this is the detail that breaks the first implementation almost everywhere. They index characters in the generated string — not tokens, not words, not bytes.
In a language whose characters live outside the Basic Multilingual Plane — emoji, some CJK extensions, mathematical symbols — a JavaScript string index and a Unicode code point index disagree, because JavaScript counts UTF-16 code units. An answer containing a single emoji before a cited span shifts every subsequent naive slice() by one. Python’s string indexing counts code points and behaves differently again. Test with non-Latin output before you trust the arithmetic; a highlight that is off by one is far harder to notice in review than one that is off by forty.
The spans are also guaranteed non-overlapping and are returned in document order, which is what makes the single-pass render below correct.
Streaming complicates the arithmetic in one specific way. In v2 the citation events arrive interleaved with the content deltas, and their offsets refer to the complete text — so a citation whose end exceeds the text you have accumulated so far is not corruption, it is a span you have not finished receiving. A renderer that applies offsets to a partial buffer will throw or silently truncate. The straightforward fix is to accumulate citations during the stream and apply them only when the text is complete, showing plain text until then; the more elaborate one is to apply each citation as soon as its end is covered by the buffer, which is worth the complexity only if the highlight appearing progressively is part of the design.
The citation quality knob
Grounding costs something, and Cohere exposes the trade-off. In v1 the parameter is citation_quality, with documented values "accurate", "fast" and "off". In v2 the same choice is citation_options: {"mode": "accurate" | "fast" | "off"}.
- accurate — the higher-fidelity path, at the cost of additional latency before the answer is complete.
- fast — lower latency, coarser spans.
- off — no citations generated at all. Worth setting explicitly when you are passing documents purely as context and have no UI that shows sources, because you stop paying for work you discard.
Rendering without corrupting the text
The naive implementation loops the citations and calls string replace on each cited phrase. It is wrong for a specific and common reason: the same phrase can occur twice, and replace hits the first occurrence rather than the one at the given offset. Walk the offsets instead, in one pass:
function renderWithCitations(text, citations) {
const out = [];
let cursor = 0;
for (const c of citations) {
if (c.start > cursor) out.push({ text: text.slice(cursor, c.start) });
out.push({
text: text.slice(c.start, c.end),
sources: c.document_ids, // v1
});
cursor = c.end;
}
if (cursor < text.length) out.push({ text: text.slice(cursor) });
return out;
}Because the spans do not overlap and arrive in order, one cursor is enough and no sorting is required. Everything between citations is plain text — and the amount of it is itself a signal, since a grounded answer that is mostly uncited prose is a grounded answer whose claims are mostly not from your documents.
Citation coverage as a quality signal
The citations array is usually treated as a rendering concern — footnote markers in a chat bubble — which undersells it. It is also the only cheap, structured, per-request signal you get about whether an answer came from your data, and it costs nothing to compute:
function citedFraction(text, citations) {
const covered = citations.reduce((n, c) => n + (c.end - c.start), 0);
return text.length === 0 ? 0 : covered / text.length;
}
// Log it on every grounded request.
const coverage = citedFraction(res.text, res.citations ?? []);
const sources = new Set((res.citations ?? []).flatMap((c) => c.document_ids));Two numbers fall out and both are useful. Coverage — the fraction of the answer’s characters inside a cited span — will not approach 1.0 on a well-written answer, because connective prose is not a claim and correctly cites nothing. What matters is its distribution over your own traffic and what happens to that distribution when you change something. A retrieval change that drops mean coverage from 0.4 to 0.15 has made the model answer more from memory and less from your documents, which is precisely the regression that no unit test catches and no user reports until an answer is wrong.
The second number is the count of distinct document_ids actually cited, against the number you sent. Send twenty documents and see one cited on nearly every request, and nineteen documents of input tokens are being paid for on every call to no effect — a straightforward argument for retrieving fewer and reranking harder. See the rerank endpoint and its per-document budget.
Neither number is a correctness measure, and it would be a mistake to present either as one: a citation says the model attributed a span to a document, not that the document supports it. But an uncited claim in a product whose whole promise is grounding is worth surfacing in the interface as well as in the logs, and the array tells you exactly which spans those are.
When citations come back empty
An empty array with a perfectly good answer is not a bug, and there are four distinct reasons for it.
- Nothing was passed to ground against. No
documents, no connectors, no tool results: there is nothing to cite, and the answer is ordinary ungrounded generation. This is the distinction between grounded and ungrounded modes, and it changes what the answer is worth. - Citations were switched off. Check
citation_qualityorcitation_options.mode— including whatever your SDK wrapper sets when you did not. - The documents did not support the answer. The model answered from its own parameters. This is the case worth alerting on: an uncited answer in a RAG product is the one most likely to be confidently wrong, and it is trivially detectable because the array is empty.
- The answer contains no citable claim. “I do not have that information” correctly cites nothing.