Guide: Attach citations to Anthropic responses using message annotations

Open in

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

Install the required packages using NPM:

npm install @anthropic-ai/sdk@^0.71 ably@^2

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

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

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

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:

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

{
  "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_location for plain text, page_location for PDFs, content_block_location for custom content, or search_result_location for 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:

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:cam-wok-ink');
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 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);:

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

// 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.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: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...');
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 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:

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

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...');
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 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.

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

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

Next steps