Working with citations
Grounded prose answers cite their sources inline with [N] markers (e.g.
"Revenue grew 12% [1]."). The completed response attaches a structured
url_citation annotation for every marker, so you don’t have to parse the
prose to wire up tooltips, footnotes, or fact-checking.
Schema-based JSON responses instead return clean field values with an empty
annotations array. Citation syntax is removed from grounded prose fields;
numbers remain native JSON numbers. Strings constrained by enum, const, or
format remain exact machine values without grounding. You can inspect field
provenance by opening the request in Cemented’s report interface.
The annotation shape
Section titled “The annotation shape”Annotations live on the assistant message’s output_text content part, beside
that part’s text property. (The OpenAI SDK also exposes the combined text as
the derived response.output_text convenience accessor.) For each [N], you
get one entry:
{ "type": "url_citation", "start_index": 14, "end_index": 17, "url": "https://nvidianews.nvidia.com/...", "title": "NVIDIA Announces Q3 FY2026 Results", "cited_text": "Revenue for the third quarter ended October 26 was $35.1 billion, up 94% from a year ago"}start_index/end_indexare byte offsets of the[N]marker in the content part’stextvalue — slice them out to highlight or replace the marker.urlandtitleidentify the source.cited_textis the verbatim source excerpt that supports the cited claim.
Recipes
Section titled “Recipes”Walk the annotations right-to-left so earlier offsets stay valid as you splice into the text.
from openai import OpenAI
client = OpenAI(api_key="ck-...", base_url="https://www.cemented.ai/v1")response = client.responses.create( model="sonnet", input="What did NVIDIA report in its latest 10-Q?", extra_body={"vendor_events": False},)
text = response.output_textannotations = [ a for item in response.output if item.type == "message" for part in item.content if part.type == "output_text" for a in part.annotations if a.type == "url_citation"]
# Build a deduped footnote list and rewrite [N] markers to footnote# numbers so the answer reads naturally.seen, footnotes = {}, []for a in annotations: if a.url not in seen: seen[a.url] = len(footnotes) + 1 footnotes.append((seen[a.url], a.title, a.url, a.cited_text))
# Replace from the right so earlier offsets stay valid.for a in sorted(annotations, key=lambda x: -x.start_index): n = seen[a.url] text = text[: a.start_index] + f"[^{n}]" + text[a.end_index :]
print(text)for n, title, url, excerpt in footnotes: print(f'[^{n}]: [{title}]({url}) — "{excerpt}"')function annotateHtml(response: any): string { const message = response.output.find((o: any) => o.type === "message"); const part = message.content.find((p: any) => p.type === "output_text"); const text: string = part.text; const anns = part.annotations.filter((a: any) => a.type === "url_citation");
// Walk right-to-left so we can splice without shifting offsets. anns.sort((a: any, b: any) => b.start_index - a.start_index); let out = text; for (const a of anns) { const link = `<a href="${a.url}" title="${escapeHtml(a.cited_text ?? "")}">${escapeHtml(text.slice(a.start_index, a.end_index))}</a>`; out = out.slice(0, a.start_index) + link + out.slice(a.end_index); } return out;}