Topic 6.6
Sources and Citations
In one line
A cited answer lets the user check it. Sources travel in the stream as their own parts, the numbers in the text are matched to them, and every odd case (a number that points nowhere, a duplicate, an unsafe address, no sources at all) gets an honest display.
Think of it like this
A news article says "the council has approved the new market". A careful reader looks for a footnote saying where that came from. With a footnote, they can check. Without one, they can only trust. Citations in an answer are footnotes: small numbers that point to the place a statement came from, so the reader can check it.
Words you'll meet
New words in this topic, in plain English. Come back here whenever one feels fuzzy.
- Citation
- A marker in the text, like [1], that points to a source.
- Source
- The page or document an answer used.
- Source part
- A piece of the message (
source-urlorsource-document) that carries a source. - Retrieval
- Finding the pages or passages that match the question, before the model answers.
- Marker
- The text
[1]itself, as the model wrote it. - Deduplicate
- Keep one entry when the same thing appears more than once.
- Grounded
- Based on given material, not on the model's memory. A grounded answer can still misquote its source.
- sendStart
- A
toUIMessageStreamoption. When false, the stream does not begin a new assistant message.
Step by step
01Sources first, then text
The server in the first example is a createUIMessageStream. It writes four source-url parts, then merges the model's stream. The client receives the sources before the first word, so every marker can be resolved the moment it arrives.
02Clean the list
listSources keeps the first part for each address (a duplicate is dropped), numbers them in order of arrival, and runs each address through safeUrl. The unsafe javascript: source stays in the list with its title but gets no link.
03Turn markers into links
citer is passed to the renderer as mapText: it receives the plain text between Markdown marks and splits it at [1]-style markers. A known source becomes a superscript link, a known source without a link becomes a superscript number, an unknown number stays plain text. The result is also written to a report so the example can print it.
Read the output: [3] no link and [4] unknown. Both are the honest display of something that cannot be linked.
04A second answer with no sources
The scripted visitor asks a second question as soon as the first answer completes. This one has no sources, and the page prints a note instead of an empty list. Silence would look like the app forgot.
05More edge cases without a network
The second example needs no stream at all. It shows what the user sees while a marker is half-written, and how seven source parts (a PDF, a duplicate, a bad address, a relative one) become a short list with a "+3 more" button.
06What the server file looks like
The last code block is the same idea as a real route: your retrieval step finds passages, the route writes them as source parts, then merges the model's stream with sendStart: false.
Try it yourself
- 1
Cite something that is not there
In the first example, change the
returnsanswer to end with[9]and run it.The citations line gains
[9] unknown, and the text still reads normally. Now changeciterso an unknown marker returns""and compare: the user would no longer see that a citation was attempted. - 2
Reorder the sources
Swap the first two
writer.writesource lines and run it.[1]now points to Refunds. The numbers are only positions in your list, which is why the list and the passages you gave the model must be in the same order. - 3
Show the half marker
In the second example, replace
hideHalfMarker(cut)withcutin theconsole.logline.The
showncolumn now ends with[and[1. On a real page those flash for a few milliseconds on every citation.
Code & diagrams
The duplicate was dropped before numbering, so the sources are 1 to 3, not 1 to 4. The fourth marker in the text has no source behind it and stays plain text.
Preview
Console
Nothing logged yet.Expected console output
sources received so far: 3
citations: [1] Returns policy | [2] Refunds | [3] no link | [4] unknown
sources: 1=Returns policy, 2=Refunds, 3=Gift card terms (no link)
messages after the first answer: 2 (1 question + 1 answer = 2)
citations: none
sources: none
note shown: This answer is not from the shop's help pages.The first five rows are the same text cut at five moments. Only the cut that ends in a complete marker shows it.
Preview
Console
Nothing logged yet.Expected console output
arrived: "ast 7 days [" shown: "last 7 days "
arrived: "st 7 days [1" shown: "last 7 days "
arrived: "t 7 days [1]" shown: "t 7 days [1]"
arrived: " 7 days [1][" shown: "t 7 days [1]"
arrived: "7 days [1][2" shown: "t 7 days [1]"
1. Returns policy -> shop.example
2. document: Terms of sale (application/pdf)
3. not a url (no link)
+3 more sources behind a button
a source with no title and a bad address is listed as: "not a url (no link)"Shape of a real route. searchHelpPages is your own retrieval step (see the /rag course) and yourModel comes from a provider package such as @ai-sdk/openai, @ai-sdk/anthropic or @ai-sdk/google (check the current model names in the provider's docs).
import { convertToModelMessages, createUIMessageStream, createUIMessageStreamResponse, streamText, toUIMessageStream } from "ai";
export async function POST(req: Request) {
const { messages } = await req.json();
const passages = await searchHelpPages(lastUserText(messages)); // [{ id, title, url, text }]
const stream = createUIMessageStream({
execute: async ({ writer }) => {
// 1. sources first, in the order you will number them
for (const p of passages) writer.write({ type: "source-url", sourceId: p.id, url: p.url, title: p.title });
// 2. the answer, told to cite passages as [1], [2], … in the same order
const result = streamText({
model: yourModel,
instructions: "Answer only from the numbered passages. Cite them like [1]. If they do not contain the answer, say so.\n\n" +
passages.map((p, i) => "[" + (i + 1) + "] " + p.title + "\n" + p.text).join("\n\n"),
messages: await convertToModelMessages(messages),
});
writer.merge(toUIMessageStream({ stream: result.stream, sendStart: false }));
},
});
return createUIMessageStreamResponse({ stream });
}Break it on purpose
Errors are the best teachers. Make each change, read the error, guess what went wrong, then reveal the answer.
Break #1
Let the model stream start its own message
In the first example, delete , sendStart: false from the toUIMessageStream call and run it.
Myth vs fact
Myth
If the answer has citations, it is correct.
Fact
A citation says which source was provided. The model can attach a number to a sentence the source never says. Citations make checking possible; they are not the check.
Myth
Models give reliable URLs if I ask for sources.
Fact
A model asked for sources from memory can invent addresses that look real. Take sources from your own retrieval step, not from the model's text.
Myth
A missing source is a minor visual glitch.
Fact
A marker that points nowhere or a blank space where sources should be makes users doubt the whole answer. Show the honest state.
Myth
Sources are safe because they came from my search.
Fact
Titles and addresses can come from content other people wrote. Draw titles as text and run addresses through the link checks from Topic 6.3.
Pro corner
Extra depth for experienced readers. New to this? Skip it for now and come back later.
- ▸
Number sources by the order you give them to the model and keep that order stable for the whole answer. If you re-rank after the model answered, the markers will point to the wrong places.
- ▸
Log how often a marker is unknown. A rising rate means a prompt or retrieval change broke the contract between the passages and the model, which is easier to catch from this metric than from users.
- ▸
Hover previews are nice on a desktop. On touch screens, make the number itself open the source list item, and keep the list reachable by keyboard and screen reader, with the same numbers as the text.
- ▸
Do not cache a cited answer without its sources. A reply restored from storage with markers and no list is the worst version of this feature (Topic 5.8 on persistence).
Remember this
- 1
The problem this solves: Models say wrong things in a confident voice. A user who cannot tell where a statement came from must either trust it or ignore it. Showing the sources that were used lets them check the part that matters. But citations are only worth having if they are real, if they point to the right place, and if the page stays honest when something is missing.
- 2
Where sources come from. Not from the model's memory. Your search or retrieval step knows which pages or documents it gave to the model (the /rag course covers retrieval). The AI SDK carries them in the message as source parts:
source-urlhassourceId,urland an optionaltitle;source-documenthassourceId,mediaType, atitleand an optionalfilenamefor things without an address, like a PDF. Some providers return sources themselves, andtoUIMessageStreamResponse({ sendSources: true })forwards them. For your own retrieval, write them into the stream withcreateUIMessageStream, as the first example does. - 3
Numbered markers. The usual contract is: you give the model numbered passages and ask it to cite them as
[1],[2]. The text then contains markers, and you map the number to the source at that position in your list. The mapping is yours to validate. A marker[4]when you have three sources is not a link; show it as plain text, because making up a target is worse than showing a bare number. - 4
A citation is a claim, not proof. It shows which source the model was given, not that the sentence is supported by it. A model can attach
[1]to something document 1 never says. Label the list honestly ("Sources used"), make each source easy to open, and for high-stakes answers add a check that compares the claim with the passage (answer-quality evals are in the /llm-engineering and /rag courses). - 5
Edge cases the display must handle. *Half a marker while streaming:*
[or[1must not flash on the screen; hide a trailing[and digits until the]arrives. *Duplicates:* the same address twice becomes one entry, numbered by first appearance. *An unsafe or incomplete address:* list the title without a link (Topic 6.3 rules apply to source links too, and a relative address such asnot a urlwould silently point at your own site). *A document with no address:* show its title and type. *Many sources:* show the first few and put the rest behind a button. *No sources at all:* say so ("This answer is not from the shop's help pages") rather than leaving a blank space that looks like an omission. - 6
Order in the stream. In the example the server writes the source parts first and the text after, so by the time the first marker arrives its source is known. If sources arrive after the text, markers must wait (show them as plain numbers) until the list is complete. Do not hide the whole answer waiting for sources.
- 7
A gotcha with custom streams.
createUIMessageStreamlets you write chunks before the model starts. The merged model stream normally sends its ownstartchunk, which begins a second assistant message, so the sources end up in a message of their own. Merge withtoUIMessageStream({ stream: result.stream, sendStart: false })so the text joins the message the sources created. The break-it below shows the symptom. - 8
Presenting them. A small superscript number after the claim, linking to the numbered list below, is the common pattern. Give the link an accessible name (
aria-label="Source 1: Returns policy"), and make the tap target at least about 24 pixels on a phone, where a tiny[1]is hard to hit. Source titles come from retrieved documents or the model, so draw them as text, never as HTML.
Explain it without notes
How do sources reach the browser in the AI SDK, and what are the two kinds of source part?
How do you turn [1] markers in the text into links, and what do you do with a marker that has no source?
Does a citation prove that the statement is true? What should the UI promise?
What goes wrong if you write source parts before merging the model's stream, and how do you fix it?
Practice
Make a second numbering rule: when the same source is cited twice, the second marker links to the same list entry instead of creating a new one. Show how listSources already does this.
Add a "Sources (3)" toggle that shows the list only when the user opens it, and keeps the numbers in the text visible.
Trade-offs
- ↔
Sources first means markers resolve instantly and the list can be shown early, but the user waits for retrieval before any text appears. Text first feels faster and needs a placeholder for markers until the list arrives.
- ↔
Strict link checks on sources hide useful but unusual addresses (an internal tool, a
file:path). A title with no link is a safe default; a "copy the address" button is a good compromise for internal tools.
Done when you can
I can name the two source parts and say what each carries.
I can write sources into a stream and merge the model's stream with
sendStart: false.I can map
[n]markers to a cleaned source list and show unknown markers honestly.I can hide half-written markers while the answer streams.
I can explain what a citation does and does not prove.