Reply to messages with Anthropic API (using threads)

View as Markdown

In this section, you'll integrate the Anthropic API with your Zoom Chatbot to handle user messages in threads. When a user sends a message to the bot, your app will generate an AI-powered reply (using models like Claude 3.5) and respond in the same thread.

By the end of this section, you will have:

  • A working integration with the Anthropic API
  • Threaded AI responses in Chat
  • Session-based message history for improved conversational context

Add Anthropic integration

Create a utility function that sends a user's message to Anthropic, receives a completion response, and replies to the chat thread. This function maintains per-user conversation history to support contextual conversations.

File Path

utils/anthropic.js

Code Snippet

import dotenv from 'dotenv';
import { sendChatMessage } from './zoom-api.js';

dotenv.config();

let conversationHistory = new Map();
const decoder = new TextDecoder();
let buf = '';

/**
 * Main entry point for interacting with Anthropic API
 * @param {Object} payload - Zoom webhook payload containing toJid / message
 * @param {Object} options - { stream?: boolean, onStreamChunk?: (chunk, soFar) => void }
 */
export async function callAnthropicAPI(payload, options = {}) {
  const userJid = payload?.toJid;
  if (!userJid) {
    console.error('Error: payload.toJid is missing.');
    return;
  }

  try {
    const { stream = true, onStreamChunk = null } = options;
    const headers = buildHeaders();

    // Build conversation history
    const history = conversationHistory.get(userJid) || [];
    const userMessage = { role: 'user', content: payload.cmd || payload.message || 'Hello' };
    history.push(userMessage);

    const requestData = buildRequestBody({
      model: process.env.ANTHROPIC_MODEL || 'claude-3-5-sonnet-latest',
      messages: history,
      stream,
    });

    console.log(`Sending message to Anthropic (model=${requestData.model}, stream=${stream}) for user: ${userJid}`);

    const completion = stream
      ? await handleStreamingResponse(requestData, headers, userJid, payload, onStreamChunk)
      : await handleNonStreamingResponse(requestData, headers, userJid, payload);

    return completion;
  } catch (error) {
    console.error('Error in callAnthropicAPI:', error?.message || error);

    try {
      await sendChatMessage(
        payload?.toJid,
        'Sorry, I hit an AI model error. I will be back shortly. (Check model access/config.)',
        payload?.reply_to || null
      );
    } catch (sendError) {
      console.error('Failed to send error message to user:', sendError?.message || sendError);
    }
    throw error;
  }
}

function buildHeaders() {
  const apiKey = process.env.ANTHROPIC_API_KEY;
  if (!apiKey) throw new Error('Missing ANTHROPIC_API_KEY in environment variables.');
  if (!apiKey.startsWith('sk-ant-')) throw new Error('Invalid ANTHROPIC_API_KEY format. Should start with "sk-ant-".');

  return {
    'Content-Type': 'application/json',
    'anthropic-version': '2023-06-01',
    'x-api-key': apiKey,
  };
}

function buildRequestBody({ model, messages, stream }) {
  return {
    model,
    messages,
    system: 'You are a helpful AI assistant integrated with Chat. Provide concise, helpful responses to user questions and requests.',
    temperature: 0.7,
    max_tokens: 1000,
  };
}

/**
 * Non-streaming path using native fetch.
 */
async function handleNonStreamingResponse(requestData, headers, userJid, payload) {
  const ANTHROPIC_URL = '[https://api.anthropic.com/v1/messages](https://api.anthropic.com/v1/messages)';
  const response = await fetch(ANTHROPIC_URL, {
    method: 'POST',
    headers,
    body: JSON.stringify({ ...requestData, stream: false }),
  });

  if (!response.ok) {
    let errorData = {};
    try {
      errorData = await response.json();
    } catch {
      errorData = { message: await response.text() };
    }
    const error = new Error(`HTTP ${response.status}: ${response.statusText}`);
    error.status = response.status;
    error.response = { data: errorData };
    throw error;
  }

  const data = await response.json();
  if (!data?.content || !Array.isArray(data.content)) {
    throw new Error(`Unexpected response from Anthropic API: ${JSON.stringify(data)}`);
  }

  const completion = data.content
    .filter((b) => b.type === 'text')
    .map((b) => b.text)
    .join('\n')
    .trim();

  if (!completion) throw new Error('Empty response from Anthropic API');

  // Append assistant message & trim history
  const history = conversationHistory.get(userJid) || [];
  history.push({ role: 'assistant', content: completion });
  conversationHistory.set(userJid, history.length > 20 ? history.slice(-20) : history);

  await sendChatMessage(userJid, completion, payload.reply_to || null);
  console.log('Successfully sent response to Chat');

  return completion;
}

/**
 * Streaming path using native fetch + Web Streams.
 * Parses Server-Sent Events (SSE): frames separated by blank line, with "event:" and "data:" lines.
 */
async function handleStreamingResponse(requestData, headers, userJid, payload, onStreamChunk) {
  const ANTHROPIC_URL = '[https://api.anthropic.com/v1/messages](https://api.anthropic.com/v1/messages)';
  const sseHeaders = { ...headers, Accept: 'text/event-stream' };

  const response = await fetch(ANTHROPIC_URL, {
    method: 'POST',
    headers: sseHeaders,
    body: JSON.stringify({ ...requestData, stream: true }),
  });

  if (!response.ok) {
    let errorData = {};
    try {
      errorData = await response.json();
    } catch {
      errorData = { message: await response.text() };
    }
    const error = new Error(`HTTP ${response.status}: ${response.statusText}`);
    error.status = response.status;
    error.response = { data: errorData };
    throw error;
  }

  const reader = response.body.getReader();
  let completion = '';

  try {
    for (;;) {
      const { done, value } = await reader.read();
      if (done) break;

      buf += decoder.decode(value, { stream: true });

      // SSE frames are separated by a blank line (\n\n)
      let sep;
      while ((sep = buf.indexOf('\n\n')) !== -1) {
        const frame = buf.slice(0, sep);
        buf = buf.slice(sep + 2);

        let eventType = '';
        let dataLine = '';

        for (const line of frame.split('\n')) {
          if (line.startsWith('event:')) eventType = line.slice(6).trim();
          else if (line.startsWith('data:')) dataLine += line.slice(5).trim();
        }

        if (!dataLine) continue;
        if (dataLine === '[DONE]') continue;

        try {
          const parsed = JSON.parse(dataLine);

          if (parsed.type === 'content_block_delta' && parsed.delta?.type === 'text_delta') {
            const chunk = parsed.delta.text || '';
            if (chunk) {
              completion += chunk;
              onStreamChunk?.(chunk, completion);
            }
          }

          if (parsed.type === 'message_stop' || eventType === 'message_stop') {
            break;
          }
        } catch {
          // Ignore malformed pieces
        }
      }
    }
  } finally {
    reader.releaseLock();
  }

  if (!completion.trim()) {
    buf = '';
    throw new Error('Empty response from Anthropic streaming API');
  }

  // Append assistant message & trim history
  const history = conversationHistory.get(userJid) || [];
  history.push({ role: 'assistant', content: completion });
  conversationHistory.set(userJid, history.length > 20 ? history.slice(-20) : history);

  // Send the full completion to Zoom
  await sendChatMessage(userJid, completion, payload.reply_to || null);
  console.log('Successfully sent streaming response to Chat');

  buf = '';
  return completion;
}

export async function callAnthropicAPIStreaming(payload, onStreamChunk) {
  return callAnthropicAPI(payload, {
    stream: true,
    onStreamChunk,
  });
}

export function getConversationHistory(userJid) {
  return conversationHistory.get(userJid) || [];
}

export function clearConversationHistory(userJid) {
  conversationHistory.delete(userJid);
  console.log(`Cleared conversation history for user: ${userJid}`);
}

Update webhook to trigger Anthropic replies

Modify the bot_notification case in your webhook handler to call the Anthropic API whenever a user sends a message to the chatbot.

This implementation enables your Zoom chatbot to respond in threads using AI-generated answers, creating a conversational experience powered by Anthropic's large language models.

File Path

routes/zoom-webhookHandler.js

Code Snippet


import { callAnthropicAPI } from '../utils/anthropic.js';

// ... inside your webhook router switch statement ...
case 'bot_notification':
  console.log('Processing bot notification from Zoom Team Chat');
  await callAnthropicAPI(payload, true);
  break;