Guide: Attach citations to OpenAI responses using message annotations

Open in

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 -y

Install the required packages using NPM:

npm install openai@^4 ably@^2

Export 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:

  1. Go to the Ably dashboard and select your app.
  2. Navigate to the "Configuration" > "Rules" section from the left-hand navigation bar.
  3. Choose "Add new rule".
  4. Enter a channel name or namespace pattern (e.g. ai for all channels starting with ai:).
  5. Select the "Message annotations, updates, deletes and appends" option from the list.
  6. 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:

JavaScript

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:

JSON

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:

JavaScript

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');
API key:
DEMO ONLY

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);:

JavaScript

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_text content block
  • Collects all url_citation annotations 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.v1 summarization method
  • Uses the source domain as the annotation name for grouping in summaries

Run the publisher to see responses and citations published to Ably:

node publisher.mjs

Step 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:

JavaScript

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...');
API key:
DEMO ONLY

Run the subscriber in a separate terminal:

node subscriber.mjs

With 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:

JavaScript

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...');
API key:
DEMO ONLY

Run the citation subscriber:

node citation-subscriber.mjs

This 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.

JavaScript

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?"
);
API key:
DEMO ONLY

Step 7: Customize search behavior

OpenAI's web search tool supports configuration options to customize search behavior:

JavaScript

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 context
  • medium: Balanced approach (default)
  • high: More comprehensive context, potentially more citations

Next steps