Voisnap Docs
Real-Time APIs

ChatHub

Real-time text chat with Voisnap agents via SignalR — send messages, receive responses, and handle typing indicators.

ChatHub

ChatHub provides real-time text-based chat between users and Voisnap agents. It powers the web chat widget and can be used to embed AI chat into any web or mobile application.


Connection

wss://api.voisnap.ai/hubs/chat?agentId={agentId}&access_token={jwt}

Optional query parameters:

ParameterDescription
sessionIdResume a previous chat session
metadataURL-encoded JSON metadata passed to the agent
languageOverride detected language (BCP 47 code, e.g. es-MX)

Client → Server methods

SendMessageAsync

Send a text message from the user.

await connection.invoke("SendMessageAsync", {
  content: "Hi, I need help with my account",
  metadata: { userId: "user_12345" }
});

TypingAsync

Send a typing indicator (call repeatedly while user is typing).

await connection.invoke("TypingAsync");

CancelAsync

Cancel the agent's current response generation (if the agent is mid-reply).

await connection.invoke("CancelAsync");

EndSessionAsync

End the chat session.

await connection.invoke("EndSessionAsync");

Server → Client events

MessageReceived

The agent's reply. May arrive in streaming chunks (isChunk: true) followed by a final message.

connection.on("MessageReceived", (data) => {
  // data: {
  //   messageId: string,
  //   content: string,
  //   isChunk: boolean,
  //   isFinal: boolean,
  //   timestamp: string
  // }
  if (data.isFinal) {
    appendFinalMessage('agent', data.content);
  } else {
    updateStreamingMessage(data.messageId, data.content);
  }
});

TypingIndicator

The agent is generating a response.

connection.on("TypingIndicator", (data) => {
  // data: { isTyping: boolean }
  showTypingIndicator(data.isTyping);
});

ConversationEnded

connection.on("ConversationEnded", (data) => {
  // data: { conversationId: string, reason: string }
  console.log(`Chat ended: ${data.reason}`);
  connection.stop();
});

Error

connection.on("Error", (data) => {
  console.error(`[${data.code}] ${data.message}`);
});

React hook example

import { useEffect, useRef, useState, useCallback } from 'react';
import * as signalR from '@microsoft/signalr';
 
interface Message {
  id: string;
  role: 'user' | 'agent';
  content: string;
  timestamp: Date;
}
 
export function useVoisnapChat(agentId: string, accessToken: string) {
  const connectionRef = useRef<signalR.HubConnection | null>(null);
  const [messages, setMessages] = useState<Message[]>([]);
  const [isConnected, setIsConnected] = useState(false);
  const [agentTyping, setAgentTyping] = useState(false);
  const streamingRef = useRef<Map<string, string>>(new Map());
 
  useEffect(() => {
    const connection = new signalR.HubConnectionBuilder()
      .withUrl(
        `wss://api.voisnap.ai/hubs/chat?agentId=${agentId}&access_token=${accessToken}`
      )
      .withAutomaticReconnect()
      .build();
 
    connection.on('MessageReceived', (data) => {
      if (!data.isFinal) {
        // Stream chunk — accumulate
        streamingRef.current.set(
          data.messageId,
          (streamingRef.current.get(data.messageId) ?? '') + data.content
        );
        setMessages(prev => {
          const idx = prev.findIndex(m => m.id === data.messageId);
          const accumulated = streamingRef.current.get(data.messageId) ?? '';
          if (idx >= 0) {
            return prev.map((m, i) =>
              i === idx ? { ...m, content: accumulated } : m
            );
          }
          return [...prev, {
            id: data.messageId,
            role: 'agent',
            content: accumulated,
            timestamp: new Date(data.timestamp),
          }];
        });
      } else {
        streamingRef.current.delete(data.messageId);
      }
    });
 
    connection.on('TypingIndicator', d => setAgentTyping(d.isTyping));
    connection.on('ConversationEnded', () => {
      setIsConnected(false);
      connection.stop();
    });
 
    connection.start()
      .then(() => setIsConnected(true))
      .catch(console.error);
 
    connectionRef.current = connection;
    return () => { connection.stop(); };
  }, [agentId, accessToken]);
 
  const sendMessage = useCallback(async (content: string) => {
    if (!connectionRef.current || !isConnected) return;
 
    const userMsg: Message = {
      id: `user-${Date.now()}`,
      role: 'user',
      content,
      timestamp: new Date(),
    };
    setMessages(prev => [...prev, userMsg]);
 
    await connectionRef.current.invoke('SendMessageAsync', { content });
  }, [isConnected]);
 
  const endSession = useCallback(async () => {
    await connectionRef.current?.invoke('EndSessionAsync');
  }, []);
 
  return { messages, isConnected, agentTyping, sendMessage, endSession };
}
 
// Usage in a component:
function ChatWidget({ agentId, accessToken }: { agentId: string; accessToken: string }) {
  const { messages, isConnected, agentTyping, sendMessage } = useVoisnapChat(agentId, accessToken);
  const [input, setInput] = useState('');
 
  return (
    <div className="chat-widget">
      <div className="messages">
        {messages.map(msg => (
          <div key={msg.id} className={`message ${msg.role}`}>
            {msg.content}
          </div>
        ))}
        {agentTyping && <div className="typing-indicator">Agent is typing...</div>}
      </div>
      <div className="input-area">
        <input
          value={input}
          onChange={e => setInput(e.target.value)}
          onKeyDown={e => {
            if (e.key === 'Enter' && input.trim()) {
              sendMessage(input.trim());
              setInput('');
            }
          }}
          placeholder={isConnected ? 'Type a message...' : 'Connecting...'}
          disabled={!isConnected}
        />
        <button onClick={() => { sendMessage(input.trim()); setInput(''); }}>
          Send
        </button>
      </div>
    </div>
  );
}

On this page