This guide shows you how to attach source citations to AI responses from OpenAI's Responses API using Ably message annotations. When OpenAI provides citations from web 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 OpenAI API key
- An Ably API key
Useful links:
Create a new NPM package, which will contain the publisher and subscriber code:
mkdir ably-openai-citations && cd ably-openai-citations
npm init -yInstall the required packages using NPM:
npm install openai@^4 ably@^2Export your OpenAI API key to the environment, which will be used later in the guide by the OpenAI SDK:
export OPENAI_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 OpenAI
Initialize an OpenAI client and use the Responses API with web search enabled. When web search is used, OpenAI includes url_citation annotations in the response.
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
import Ably from 'ably';
import OpenAI from 'openai';
// Initialize OpenAI client
const openai = new OpenAI();
// Create response with web search enabled
async function getOpenAIResponseWithCitations(question) {
const response = await openai.responses.create({
model: "gpt-5",
input: question,
tools: [{ type: "web_search_preview" }]
});
console.log(JSON.stringify(response, null, 2));
}
// Usage example
getOpenAIResponseWithCitations(
"What are the latest discoveries from the James Webb Space Telescope in 2025?"
);Understand OpenAI citation responses
When web search is enabled, OpenAI's Responses API returns responses with url_citation annotations embedded in the output. The response includes both the web search call and the message with citations.
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
37
38
{
"id": "resp_abc123",
"status": "completed",
"output": [
{
"type": "web_search_call",
"id": "ws_456",
"status": "completed"
},
{
"type": "message",
"id": "msg_789",
"role": "assistant",
"content": [
{
"type": "output_text",
"text": "The James Webb Space Telescope launched on December 25, 2021 [1]. Its first full-color images were released on July 12, 2022 [2].",
"annotations": [
{
"type": "url_citation",
"start_index": 51,
"end_index": 54,
"url": "https://science.nasa.gov/mission/webb/",
"title": "James Webb Space Telescope - NASA Science"
},
{
"type": "url_citation",
"start_index": 110,
"end_index": 113,
"url": "https://en.wikipedia.org/wiki/James_Webb_Space_Telescope",
"title": "James Webb Space Telescope - Wikipedia"
}
]
}
]
}
]
}Each url_citation annotation includes:
type: Always"url_citation"for web search citations.start_index: The character position in the response text where the citation marker begins.end_index: The character position where the citation marker ends.url: The source URL being cited.title: The title of the source page.
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:arc-tab-pet');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 text and citations from the OpenAI response, then publish them to Ably. Update getOpenAIResponseWithCitations 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
// Process response and publish to Ably
async function processResponse(response) {
let fullText = '';
const citations = [];
// Extract text and citations from response
for (const item of response.output) {
if (item.type === 'message') {
for (const content of item.content) {
if (content.type === 'output_text') {
fullText = content.text;
if (content.annotations) {
for (const annotation of content.annotations) {
if (annotation.type === 'url_citation') {
citations.push({
url: annotation.url,
title: annotation.title,
startIndex: annotation.start_index,
endIndex: annotation.end_index
});
}
}
}
}
}
}
}
// 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) {
const sourceDomain = new URL(citation.url).hostname;
await channel.annotations.publish(msgSerial, {
type: 'citations:multiple.v1',
name: sourceDomain,
data: {
url: citation.url,
title: citation.title,
startIndex: citation.startIndex,
endIndex: citation.endIndex
}
});
}
console.log(`Published ${citations.length} citation(s)`);
}This implementation:
- Extracts the response text from the
output_textcontent block - Collects all
url_citationannotations with their URLs, titles, and positions - 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:arc-tab-pet');
// 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 domain.
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
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:arc-tab-pet', {
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 { url, title, startIndex, endIndex } = annotation.data;
console.log('\n[Citation received]');
console.log(` Title: ${title}`);
console.log(` URL: ${url}`);
console.log(` Position: ${startIndex}-${endIndex}`);
// 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 clickable source links with titles and URLs
- Link inline citation markers (like
[1]) to their sources using the position indices - Build a references section with all cited sources
Step 6: Combine with streaming responses
You can combine citations with the message-per-response streaming pattern. OpenAI's streaming responses include citation annotations in the final response.output_text.done event.
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
import OpenAI from 'openai';
import Ably from 'ably';
const openai = new OpenAI();
const realtime = new Ably.Realtime({
key: 'demokey:*****',
echoMessages: false
});
const channel = realtime.channels.get('ai:arc-tab-pet');
// Track state for streaming
let msgSerial = null;
let messageItemId = null;
// Process streaming events
async function processStreamEvent(event) {
switch (event.type) {
case 'response.created':
// Publish initial empty message
const result = await channel.publish({ name: 'response', data: '' });
msgSerial = result.serials[0];
break;
case 'response.output_item.added':
if (event.item.type === 'message') {
messageItemId = event.item.id;
}
break;
case 'response.output_text.delta':
// Append text token
if (event.item_id === messageItemId && msgSerial) {
channel.appendMessage({ serial: msgSerial, data: event.delta });
}
break;
case 'response.output_text.done':
// Process citations when text output is complete
if (event.item_id === messageItemId && event.annotations) {
for (const annotation of event.annotations) {
if (annotation.type === 'url_citation') {
const sourceDomain = new URL(annotation.url).hostname;
await channel.annotations.publish(msgSerial, {
type: 'citations:multiple.v1',
name: sourceDomain,
data: {
url: annotation.url,
title: annotation.title,
startIndex: annotation.start_index,
endIndex: annotation.end_index
}
});
}
}
}
break;
case 'response.completed':
console.log('Stream completed!');
break;
}
}
// Stream response with web search
async function streamWithCitations(question) {
const stream = await openai.responses.create({
model: "gpt-5",
input: question,
tools: [{ type: "web_search_preview" }],
stream: true
});
for await (const event of stream) {
await processStreamEvent(event);
}
}
// Example usage
await streamWithCitations(
"What are the latest discoveries from the James Webb Space Telescope in 2025?"
);Step 7: Customize search behavior
OpenAI's web search tool supports configuration options to customize search behavior:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
// Customize search context size
const response = await openai.responses.create({
model: "gpt-5",
input: "What are the latest AI developments?",
tools: [{
type: "web_search_preview",
search_context_size: "high" // Options: "low", "medium", "high"
}]
});
// Filter to specific domains
const response = await openai.responses.create({
model: "gpt-5",
input: "Find information about the James Webb Space Telescope",
tools: [{
type: "web_search_preview",
user_location: {
type: "approximate",
country: "US"
}
}]
});The search_context_size option controls how much context from search results is provided to the model:
low: Faster responses with less contextmedium: Balanced approach (default)high: More comprehensive context, potentially more citations
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