This guide shows you how to attach source citations to AI responses from Anthropic's Messages API using Ably message annotations. When Anthropic provides citations from documents or search results, you can publish them as annotations on Ably messages, enabling clients to display source references alongside AI responses in realtime.
Attaching citations to AI responses enables your users to see the original sources that were used in the generated response, explore topics in depth, and properly attribute the source content creators. Citations provide explicit traceability between the generated response and the information sources that were used when generating them.
Ably message annotations let you separate citation metadata from response content, display citation summaries updated in realtime, and retrieve detailed citation data on demand.
Prerequisites
To follow this guide, you need:
- Node.js 20 or higher
- An Anthropic API key
- An Ably API key
Useful links:
Create a new NPM package, which will contain the publisher and subscriber code:
mkdir ably-anthropic-citations && cd ably-anthropic-citations
npm init -yInstall the required packages using NPM:
npm install @anthropic-ai/sdk@^0.71 ably@^2Export your Anthropic API key to the environment, which will be used later in the guide by the Anthropic SDK:
export ANTHROPIC_API_KEY="your_api_key_here"Step 1: Enable message annotations
Message annotations require "Message annotations, updates, deletes and appends" to be enabled in a channel rule associated with the channel.
To enable the channel rule:
- Go to the Ably dashboard and select your app.
- Navigate to the "Configuration" > "Rules" section from the left-hand navigation bar.
- Choose "Add new rule".
- Enter a channel name or namespace pattern (e.g.
aifor all channels starting withai:). - Select the "Message annotations, updates, deletes and appends" option from the list.
- Click "Create channel rule".
The examples in this guide use the ai: namespace prefix, which assumes you have configured the rule for ai:*.
Step 2: Get a response with citations from Anthropic
Initialize an Anthropic client and use the Messages API with citations enabled. Anthropic supports citations from documents, PDFs, and search results.
Create a new file publisher.mjs with the following contents:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
import Ably from 'ably';
import Anthropic from '@anthropic-ai/sdk';
// Initialize Anthropic client
const anthropic = new Anthropic();
// Create a response with citations enabled
async function getAnthropicResponseWithCitations(question, documentContent) {
const response = await anthropic.messages.create({
model: "claude-sonnet-4-5",
max_tokens: 1024,
messages: [
{
role: "user",
content: [
{
type: "document",
source: {
type: "text",
media_type: "text/plain",
data: documentContent
},
title: "Source Document",
citations: { enabled: true }
},
{
type: "text",
text: question
}
]
}
]
});
console.log(JSON.stringify(response, null, 2));
}
// Usage example
const document = `The James Webb Space Telescope (JWST) launched on December 25, 2021.
It is the largest optical telescope in space and is designed to conduct infrared astronomy.
The telescope's first full-color images were released on July 12, 2022, revealing unprecedented
details of distant galaxies, nebulae, and exoplanet atmospheres.`;
getAnthropicResponseWithCitations(
"What are the latest discoveries from the James Webb Space Telescope?",
document
);Understand Anthropic citation responses
When citations are enabled, Anthropic's Messages API returns responses with multiple text blocks. Each text block can include a citations array containing references to specific locations in the source documents.
The following example shows the response structure when citations are included:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
{
"content": [
{
"type": "text",
"text": "The James Webb Space Telescope launched on "
},
{
"type": "text",
"text": "December 25, 2021",
"citations": [{
"type": "char_location",
"cited_text": "The James Webb Space Telescope (JWST) launched on December 25, 2021.",
"document_index": 0,
"document_title": "Source Document",
"start_char_index": 0,
"end_char_index": 68
}]
},
{
"type": "text",
"text": ". Its first full-color images were released on "
},
{
"type": "text",
"text": "July 12, 2022",
"citations": [{
"type": "char_location",
"cited_text": "The telescope's first full-color images were released on July 12, 2022",
"document_index": 0,
"document_title": "Source Document",
"start_char_index": 185,
"end_char_index": 255
}]
}
]
}Each citation includes:
type: The citation type (char_locationfor plain text,page_locationfor PDFs,content_block_locationfor custom content, orsearch_result_locationfor search results).cited_text: The exact text being cited from the source.document_index: The index of the source document (0-indexed).document_title: The title of the source document.- Location fields: Character indices, page numbers, or block indices depending on the citation type.
Step 3: Publish response and citations to Ably
Publish the AI response as an Ably message, then publish each citation as a message annotation referencing the response message's serial.
Initialize the Ably client
Add the Ably import and client initialization to your publisher.mjs file:
1
2
3
4
5
6
7
8
// Initialize Ably Realtime client
const realtime = new Ably.Realtime({
key: 'demokey:*****',
echoMessages: false
});
// Create a channel for publishing AI responses
const channel = realtime.channels.get('ai:cam-wok-ink');The Ably Realtime client maintains a persistent connection to the Ably service, which allows you to publish messages with low latency.
Publish response and citations
Add a processResponse function to extract the full text and citations from the Anthropic response, then publish them to Ably. Update getAnthropicResponseWithCitations to call it by replacing the console.log line with await processResponse(response);:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
// Process response and publish to Ably
async function processResponse(response) {
let fullText = '';
const citations = [];
let currentOffset = 0;
// Extract text and citations from response
for (const block of response.content) {
if (block.type === 'text') {
const text = block.text;
if (block.citations) {
for (const citation of block.citations) {
citations.push({
...citation,
// Track position in the full response text
responseStartOffset: currentOffset,
responseEndOffset: currentOffset + text.length
});
}
}
fullText += text;
currentOffset += text.length;
}
}
// Publish the AI response message
const { serials: [msgSerial] } = await channel.publish('response', fullText);
console.log('Published response with serial:', msgSerial);
// Publish each citation as an annotation
for (const citation of citations) {
let sourceDomain;
try {
sourceDomain = citation.source ? new URL(citation.source).hostname : citation.document_title;
} catch {
sourceDomain = citation.document_title || 'document';
}
await channel.annotations.publish(msgSerial, {
type: 'citations:multiple.v1',
name: sourceDomain,
data: {
title: citation.document_title,
citedText: citation.cited_text,
citationType: citation.type,
startOffset: citation.responseStartOffset,
endOffset: citation.responseEndOffset,
documentIndex: citation.document_index,
...(citation.start_char_index !== undefined && {
startCharIndex: citation.start_char_index,
endCharIndex: citation.end_char_index
}),
...(citation.start_page_number !== undefined && {
startPageNumber: citation.start_page_number,
endPageNumber: citation.end_page_number
})
}
});
}
console.log(`Published ${citations.length} citation(s)`);
}This implementation:
- Extracts the full response text by concatenating all text blocks
- Tracks the position of each citation within the full response
- Publishes the response as a single Ably message and captures its
serial - Publishes each citation as an annotation using the
multiple.v1summarization method - Uses the source domain as the annotation
namefor grouping in summaries
Run the publisher to see responses and citations published to Ably:
node publisher.mjsStep 4: Subscribe to citation summaries
Create a subscriber that receives AI responses and citation summaries in realtime.
Create a new file subscriber.mjs with the following contents:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
import Ably from 'ably';
// Initialize Ably Realtime client
const realtime = new Ably.Realtime({ key: 'demokey:*****' });
// Get the same channel used by the publisher
const channel = realtime.channels.get('ai:cam-wok-ink');
// Track responses
const responses = new Map();
// Subscribe to receive messages and summaries
await channel.subscribe((message) => {
switch (message.action) {
case 'message.create':
console.log('\n[New response]');
console.log('Serial:', message.serial);
console.log('Content:', message.data);
responses.set(message.serial, { content: message.data, citations: {} });
break;
case 'message.summary':
const citationsSummary = message.annotations?.summary['citations:multiple.v1'];
if (citationsSummary) {
console.log('\n[Citation summary updated]');
for (const [source, data] of Object.entries(citationsSummary)) {
console.log(` ${source}: ${data.total} citation(s)`);
}
}
break;
}
});
console.log('Subscriber ready, waiting for responses and citations...');Run the subscriber in a separate terminal:
node subscriber.mjsWith the subscriber running, run the publisher in another terminal. You'll see the response appear followed by citation summary updates showing counts grouped by source document.
Step 5: Subscribe to individual citations
To access the full citation data for rendering source links or inline markers, subscribe to individual annotation events.
Create a new file citation-subscriber.mjs with the following contents:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
import Ably from 'ably';
// Initialize Ably Realtime client
const realtime = new Ably.Realtime({ key: 'demokey:*****' });
// Get the channel with annotation subscription enabled
const channel = realtime.channels.get('ai:cam-wok-ink', {
modes: ['SUBSCRIBE', 'ANNOTATION_SUBSCRIBE']
});
// Track responses and their citations
const responses = new Map();
// Subscribe to messages
await channel.subscribe((message) => {
if (message.action === 'message.create') {
console.log('\n[New response]');
console.log('Serial:', message.serial);
console.log('Content:', message.data);
responses.set(message.serial, { content: message.data, citations: [] });
}
});
// Subscribe to individual citation annotations
await channel.annotations.subscribe((annotation) => {
if (annotation.action === 'annotation.create' &&
annotation.type === 'citations:multiple.v1') {
const { title, citedText, citationType, documentIndex } = annotation.data;
console.log('\n[Citation received]');
console.log(` Source: ${title}`);
console.log(` Type: ${citationType}`);
console.log(` Document index: ${documentIndex}`);
console.log(` Cited text: "${citedText}"`);
// Store citation for the response
const response = responses.get(annotation.messageSerial);
if (response) {
response.citations.push(annotation.data);
}
}
});
console.log('Subscriber ready, waiting for responses and citations...');Run the citation subscriber:
node citation-subscriber.mjsThis subscriber receives the full citation data as each annotation arrives, enabling you to:
- Display the source document title
- Show the exact text that was cited from each source
- Highlight cited portions of the response text using the offset positions
Step 6: Combine with streaming responses
You can combine citations with the message-per-response streaming pattern. Since Anthropic includes citations_delta events when streaming, you can publish citations as annotations while the response is still being streamed.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
import Anthropic from '@anthropic-ai/sdk';
import Ably from 'ably';
const anthropic = new Anthropic();
const realtime = new Ably.Realtime({
key: 'demokey:*****',
echoMessages: false
});
const channel = realtime.channels.get('ai:cam-wok-ink');
// Track state for streaming
let msgSerial = null;
let currentBlockIndex = null;
let currentOffset = 0;
// Process streaming events
async function processStreamEvent(event) {
switch (event.type) {
case 'message_start':
// Publish initial empty message
const result = await channel.publish({ name: 'response', data: '' });
msgSerial = result.serials[0];
currentOffset = 0;
break;
case 'content_block_start':
if (event.content_block.type === 'text') {
currentBlockIndex = event.index;
}
break;
case 'content_block_delta':
if (event.index === currentBlockIndex) {
if (event.delta.type === 'text_delta') {
// Append text token
channel.appendMessage({ serial: msgSerial, data: event.delta.text });
currentOffset += event.delta.text.length;
} else if (event.delta.type === 'citations_delta') {
// Publish citation annotation
const citation = event.delta.citation;
let sourceDomain;
try {
sourceDomain = new URL(citation.source || '').hostname;
} catch {
sourceDomain = citation.document_title || 'document';
}
await channel.annotations.publish(msgSerial, {
type: 'citations:multiple.v1',
name: sourceDomain,
data: {
title: citation.document_title,
citedText: citation.cited_text,
citationType: citation.type,
documentIndex: citation.document_index
}
});
}
}
break;
case 'message_stop':
console.log('Stream completed!');
break;
}
}
// Stream response with citations
async function streamWithCitations(question, documentContent) {
const stream = await anthropic.messages.create({
model: "claude-sonnet-4-5",
max_tokens: 1024,
stream: true,
messages: [
{
role: "user",
content: [
{
type: "document",
source: {
type: "text",
media_type: "text/plain",
data: documentContent
},
title: "Source Document",
citations: { enabled: true }
},
{
type: "text",
text: question
}
]
}
]
});
for await (const event of stream) {
await processStreamEvent(event);
}
}
// Example usage
const document = "The James Webb Space Telescope (JWST) launched on December 25, 2021. It is the largest optical telescope in space and is designed to conduct infrared astronomy. The telescope's first full-color images were released on July 12, 2022, revealing unprecedented details of distant galaxies, nebulae, and exoplanet atmospheres.";
await streamWithCitations("What are the latest discoveries from the James Webb Space Telescope?", document);Next steps
- Learn more about citations and message annotations
- Explore annotation summaries for displaying citation counts
- Understand how to retrieve annotations on demand via the REST API
- Combine with message-per-response streaming for live token delivery