Real Time Block

Complete real-time communication for any application
The Real Time Block provides everything you need for instant, bidirectional communication. Build chat applications, video conferencing, live collaboration, and real-time dashboards with WebSocket connections, WebRTC support, presence tracking, and message history.
Features
| Feature | Description |
|---|---|
| WebSocket Connections | Persistent bidirectional connections with auto-reconnect |
| Channels & Rooms | Organize communication with public, private, and presence channels |
| Presence Tracking | Know who is online with custom status states |
| WebRTC Support | Peer-to-peer video, audio, and screen sharing |
| Message History | Persist and retrieve past messages with pagination |
| Broadcast Events | Send messages to thousands of subscribers instantly |
| Typing Indicators | Show real-time typing status to other users |
| Read Receipts | Track message delivery and read status |
| AI Conversation Summaries | LLM-powered structured summaries with categories, action items, and stats |
| AI Cross-Conversation Digest | Aggregate all unread conversations into a single AI-generated digest |
| Smart Unread Tracking | Per-conversation unread counts with role-aware notifications and filtering |
API Endpoint
| Service | URL |
|---|---|
| Real Time | https://conversations.api.us.23blocks.com |
Environment Routing: Use the same URL for all environments. Your API key determines the environment:
pk_test_*/sk_test_*→ Routes to Stagingpk_live_*/sk_live_*→ Routes to Production
Quick Start
Installation
npm install @23blocks/sdk
Initialize the Client
import { create23BlocksClient } from '@23blocks/sdk';
const client = create23BlocksClient({
urls: { realtime: 'https://conversations.api.us.23blocks.com' },
apiKey: 'your-api-key', // Use pk_test_* for staging, pk_live_* for production
});
Basic Usage
// Connect to real-time service
const realtime = await client.realtime.connect({
token: 'user-auth-token'
});
// Subscribe to a channel
const channel = realtime.subscribe('chat:room-1');
// Listen for messages
channel.on('message', (message) => {
console.log('New message:', message);
});
// Listen for presence changes
channel.on('presence', (users) => {
console.log('Online users:', users);
});
// Send a message
channel.send({
type: 'message',
text: 'Hello, everyone!',
user_id: 'user_123'
});
// Track typing status
channel.typing.start();
// ... after user stops typing
channel.typing.stop();
API Reference
Base URL
wss://realtime.23blocks.com
https://api.23blocks.com/v1/realtime
Authentication
WebSocket connections require a connection token:
// Get connection token
const { token } = await client.realtime.getToken({
user_id: 'user_123',
channels: ['chat:*', 'notifications:user_123']
});
// Connect with token
const realtime = await client.realtime.connect({ token });
REST API calls require standard headers:
X-App-Id: your-app-id
X-Api-Key: your-api-key
Authorization: Bearer <user-token>
Connection API
Manage WebSocket connections with automatic reconnection and state handling.
Connect
Establish a WebSocket connection.
const realtime = await client.realtime.connect({
token: 'connection-token',
options: {
autoReconnect: true,
reconnectDelay: 1000,
maxReconnectAttempts: 10,
heartbeatInterval: 30000
}
});
Connection Events:
realtime.on('connected', () => {
console.log('Connected to real-time service');
});
realtime.on('disconnected', (reason) => {
console.log('Disconnected:', reason);
});
realtime.on('reconnecting', (attempt) => {
console.log('Reconnecting, attempt:', attempt);
});
realtime.on('error', (error) => {
console.error('Connection error:', error);
});
Disconnect
Close the WebSocket connection.
realtime.disconnect();
Connection State
Check connection status.
const state = realtime.getState();
// 'connecting' | 'connected' | 'disconnecting' | 'disconnected'
const isConnected = realtime.isConnected();
Channels API
Organize communication with channels. Channels can be public, private, or presence-enabled.
Channel Types
| Type | Prefix | Description |
|---|---|---|
| Public | public: | Anyone can subscribe and send messages |
| Private | private: | Requires authorization to subscribe |
| Presence | presence: | Includes presence tracking for subscribers |
Subscribe to Channel
// Subscribe to a public channel
const channel = realtime.subscribe('public:news');
// Subscribe to a private channel
const privateChannel = realtime.subscribe('private:team-123');
// Subscribe to a presence channel
const presenceChannel = realtime.subscribe('presence:room-1');
Channel Events
// Message received
channel.on('message', (message) => {
console.log('Message:', message);
});
// Custom event
channel.on('custom-event', (data) => {
console.log('Custom event:', data);
});
// Presence updates (presence channels only)
channel.on('presence:join', (user) => {
console.log('User joined:', user);
});
channel.on('presence:leave', (user) => {
console.log('User left:', user);
});
channel.on('presence:update', (user) => {
console.log('User updated:', user);
});
Send Message
channel.send({
type: 'message',
data: {
text: 'Hello, world!',
attachments: []
}
});
Send Custom Event
channel.trigger('cursor-move', {
x: 100,
y: 200,
user_id: 'user_123'
});
Unsubscribe
channel.unsubscribe();
Presence API
Track user presence and custom status in real-time.
Get Presence Members
const members = await channel.presence.getMembers();
// Returns array of currently present users
console.log(members);
// [
// { user_id: 'user_1', status: 'online', data: { name: 'Alice' } },
// { user_id: 'user_2', status: 'away', data: { name: 'Bob' } }
// ]
Track Presence
// Join with custom data
channel.presence.enter({
name: 'Alice',
avatar: 'https://example.com/alice.jpg',
status: 'online'
});
// Update presence data
channel.presence.update({
status: 'busy',
statusMessage: 'In a meeting'
});
// Leave presence
channel.presence.leave();
Presence Events
channel.on('presence:join', (member) => {
console.log(`${member.data.name} joined`);
});
channel.on('presence:leave', (member) => {
console.log(`${member.data.name} left`);
});
channel.on('presence:update', (member) => {
console.log(`${member.data.name} is now ${member.data.status}`);
});
channel.on('presence:sync', (members) => {
console.log('Full member list:', members);
});
Typing Indicators API
Show real-time typing status to other users.
Start Typing
channel.typing.start({
user_id: 'user_123',
user_name: 'Alice'
});
Stop Typing
channel.typing.stop();
Listen for Typing
channel.on('typing:start', (user) => {
console.log(`${user.user_name} is typing...`);
});
channel.on('typing:stop', (user) => {
console.log(`${user.user_name} stopped typing`);
});
Message History API
Persist and retrieve past messages.
Get Message History
GET /v1/realtime/channels/:channel_id/messages
Query Parameters:
| Parameter | Type | Description |
|---|---|---|
limit | integer | Max messages to return (default: 50, max: 100) |
before | string | Get messages before this message ID |
after | string | Get messages after this message ID |
order | string | Sort order: asc or desc (default: desc) |
Response:
{
"data": [
{
"id": "msg_abc123",
"type": "messages",
"attributes": {
"channel_id": "chat:room-1",
"user_id": "user_123",
"event": "message",
"data": {
"text": "Hello, everyone!",
"attachments": []
},
"created_at": "2024-01-15T10:30:00Z"
}
}
],
"meta": {
"has_more": true,
"oldest_id": "msg_xyz789",
"newest_id": "msg_abc123"
}
}
SDK Usage
// Get latest messages
const messages = await channel.history.fetch({
limit: 50
});
// Get older messages (pagination)
const olderMessages = await channel.history.fetch({
limit: 50,
before: 'msg_xyz789'
});
// Search messages
const searchResults = await client.realtime.messages.search({
channel_id: 'chat:room-1',
query: 'meeting',
limit: 20
});
Read Receipts API
Track message delivery and read status.
Mark as Read
POST /v1/realtime/channels/:channel_id/read
Request Body:
{
"data": {
"type": "read_receipt",
"attributes": {
"message_id": "msg_abc123",
"user_id": "user_456"
}
}
}
SDK Usage
// Mark messages as read
await channel.messages.markAsRead('msg_abc123');
// Get read status
const readStatus = await channel.messages.getReadStatus('msg_abc123');
// { read_by: ['user_123', 'user_456'], read_count: 2 }
Listen for Read Events
channel.on('message:read', (data) => {
console.log(`Message ${data.message_id} read by ${data.user_id}`);
});
WebRTC API
Peer-to-peer video, audio, and screen sharing.
Create a Room
POST /v1/realtime/webrtc/rooms
Request Body:
{
"data": {
"type": "webrtc_rooms",
"attributes": {
"name": "Team Meeting",
"max_participants": 10,
"recording_enabled": true,
"settings": {
"video": {
"enabled": true,
"quality": "hd"
},
"audio": {
"enabled": true,
"noise_suppression": true
},
"screen_sharing": {
"enabled": true
}
}
}
}
}
Response:
{
"data": {
"id": "room_abc123",
"type": "webrtc_rooms",
"attributes": {
"name": "Team Meeting",
"join_url": "https://meet.23blocks.com/room_abc123",
"token": "eyJ...",
"max_participants": 10,
"recording_enabled": true,
"settings": {...},
"created_at": "2024-01-15T10:30:00Z"
}
}
}
Join a Room
const room = await client.realtime.webrtc.join({
room_id: 'room_abc123',
token: 'room-token',
audio: true,
video: true
});
// Listen for participants
room.on('participant:joined', (participant) => {
console.log('Participant joined:', participant);
// Add their video stream to UI
addVideoElement(participant.stream);
});
room.on('participant:left', (participant) => {
console.log('Participant left:', participant);
removeVideoElement(participant.id);
});
Media Controls
// Toggle video
room.video.toggle();
room.video.enable();
room.video.disable();
// Toggle audio
room.audio.toggle();
room.audio.mute();
room.audio.unmute();
// Share screen
await room.screen.share();
room.screen.stop();
Leave Room
room.leave();
Broadcast API
Send messages from your server to all subscribers.
Publish to Channel
POST /v1/realtime/publish
Request Body:
{
"data": {
"type": "broadcast",
"attributes": {
"channel": "notifications:all",
"event": "announcement",
"data": {
"title": "System Maintenance",
"message": "Scheduled maintenance in 1 hour",
"type": "warning"
}
}
}
}
Batch Publish
POST /v1/realtime/publish/batch
Request Body:
{
"data": {
"type": "batch_broadcast",
"attributes": {
"messages": [
{
"channel": "notifications:user_123",
"event": "notification",
"data": { "message": "You have a new message" }
},
{
"channel": "notifications:user_456",
"event": "notification",
"data": { "message": "Your order shipped" }
}
]
}
}
}
Webhooks
Receive real-time events on your server.
Supported Events
| Event | Description |
|---|---|
channel.subscribed | User subscribed to a channel |
channel.unsubscribed | User unsubscribed from a channel |
message.sent | Message sent to a channel |
presence.join | User joined presence channel |
presence.leave | User left presence channel |
presence.update | User updated presence data |
webrtc.room.created | WebRTC room created |
webrtc.participant.joined | Participant joined WebRTC room |
webrtc.participant.left | Participant left WebRTC room |
webrtc.recording.ready | Room recording is ready |
Webhook Payload
{
"id": "evt_abc123",
"type": "message.sent",
"created_at": "2024-01-15T10:30:00Z",
"data": {
"channel_id": "chat:room-1",
"user_id": "user_123",
"message": {
"id": "msg_xyz789",
"text": "Hello, world!",
"created_at": "2024-01-15T10:30:00Z"
}
}
}
Configure Webhooks
POST /v1/realtime/webhooks
Request Body:
{
"data": {
"type": "webhooks",
"attributes": {
"url": "https://api.yourapp.com/webhooks/realtime",
"events": ["message.sent", "presence.join", "presence.leave"],
"secret": "whsec_...",
"active": true
}
}
}
Channel Authorization
Control access to private and presence channels.
Authorization Endpoint
When a user tries to subscribe to a private or presence channel, your server must authorize the request.
POST /your-app/auth/realtime
Your server receives:
{
"socket_id": "socket_abc123",
"channel_name": "private:team-123",
"user_id": "user_123"
}
Your server responds:
{
"auth": "app_id:signature",
"channel_data": {
"user_id": "user_123",
"user_info": {
"name": "Alice",
"avatar": "https://example.com/alice.jpg"
}
}
}
SDK Configuration
const realtime = await client.realtime.connect({
token: 'connection-token',
authEndpoint: 'https://api.yourapp.com/auth/realtime',
authHeaders: {
'Authorization': 'Bearer user-session-token'
}
});
Error Handling
Error Response Format
{
"errors": [
{
"status": "401",
"code": "unauthorized_channel",
"title": "Unauthorized",
"detail": "You are not authorized to subscribe to this channel"
}
]
}
Common Error Codes
| Status | Code | Description |
|---|---|---|
| 400 | invalid_channel | Invalid channel name format |
| 401 | invalid_token | Connection token is invalid or expired |
| 401 | unauthorized_channel | Not authorized to access this channel |
| 403 | channel_limit_exceeded | Maximum channel subscriptions reached |
| 404 | channel_not_found | Channel does not exist |
| 429 | rate_limit_exceeded | Too many messages |
| 500 | connection_failed | Failed to establish connection |
SDK Examples
TypeScript/JavaScript
import { create23BlocksClient } from '@23blocks/sdk';
const client = create23BlocksClient({
urls: { realtime: 'https://conversations.api.us.23blocks.com' },
appId: process.env.APP_ID,
apiKey: process.env.API_KEY,
});
// Initialize real-time connection
async function initRealtime(userId: string) {
// Get connection token
const { token } = await client.realtime.getToken({
user_id: userId,
channels: ['chat:*', `notifications:${userId}`]
});
// Connect
const realtime = await client.realtime.connect({ token });
// Handle connection events
realtime.on('connected', () => {
console.log('Connected!');
});
realtime.on('error', (error) => {
console.error('Connection error:', error);
});
return realtime;
}
// Chat room example
async function joinChatRoom(realtime: any, roomId: string, userId: string) {
const channel = realtime.subscribe(`presence:chat:${roomId}`);
// Enter presence
channel.presence.enter({
user_id: userId,
name: 'Alice',
avatar: 'https://example.com/alice.jpg'
});
// Listen for messages
channel.on('message', (message) => {
displayMessage(message);
});
// Listen for presence
channel.on('presence:join', (member) => {
addUserToList(member);
});
channel.on('presence:leave', (member) => {
removeUserFromList(member);
});
// Listen for typing
channel.on('typing:start', (user) => {
showTypingIndicator(user);
});
channel.on('typing:stop', (user) => {
hideTypingIndicator(user);
});
return channel;
}
// Send message
function sendMessage(channel: any, text: string) {
channel.send({
type: 'message',
data: {
text,
timestamp: new Date().toISOString()
}
});
}
React Chat Component
import React, { useEffect, useState, useRef } from 'react';
import { create23BlocksClient, RealtimeChannel } from '@23blocks/sdk';
interface Message {
id: string;
user_id: string;
user_name: string;
text: string;
created_at: string;
}
interface User {
user_id: string;
name: string;
avatar: string;
}
const client = create23BlocksClient({
urls: { realtime: 'https://conversations.api.us.23blocks.com' },
appId: process.env.REACT_APP_APP_ID!,
apiKey: process.env.REACT_APP_API_KEY!,
});
export function ChatRoom({ roomId, currentUser }: { roomId: string; currentUser: User }) {
const [messages, setMessages] = useState<Message[]>([]);
const [members, setMembers] = useState<User[]>([]);
const [typingUsers, setTypingUsers] = useState<string[]>([]);
const [inputValue, setInputValue] = useState('');
const [isConnected, setIsConnected] = useState(false);
const channelRef = useRef<RealtimeChannel | null>(null);
const typingTimeoutRef = useRef<NodeJS.Timeout | null>(null);
useEffect(() => {
let realtime: any;
async function connect() {
// Get token and connect
const { token } = await client.realtime.getToken({
user_id: currentUser.user_id,
channels: [`presence:chat:${roomId}`]
});
realtime = await client.realtime.connect({ token });
realtime.on('connected', () => {
setIsConnected(true);
});
// Subscribe to chat room
const channel = realtime.subscribe(`presence:chat:${roomId}`);
channelRef.current = channel;
// Enter presence
channel.presence.enter({
user_id: currentUser.user_id,
name: currentUser.name,
avatar: currentUser.avatar
});
// Get presence members
const currentMembers = await channel.presence.getMembers();
setMembers(currentMembers.map((m: any) => m.data));
// Load message history
const history = await channel.history.fetch({ limit: 50 });
setMessages(history.reverse());
// Listen for new messages
channel.on('message', (message: any) => {
setMessages(prev => [...prev, message.data]);
});
// Listen for presence changes
channel.on('presence:join', (member: any) => {
setMembers(prev => [...prev, member.data]);
});
channel.on('presence:leave', (member: any) => {
setMembers(prev => prev.filter(m => m.user_id !== member.data.user_id));
});
// Listen for typing indicators
channel.on('typing:start', (user: any) => {
if (user.user_id !== currentUser.user_id) {
setTypingUsers(prev => [...new Set([...prev, user.user_name])]);
}
});
channel.on('typing:stop', (user: any) => {
setTypingUsers(prev => prev.filter(name => name !== user.user_name));
});
}
connect();
return () => {
if (channelRef.current) {
channelRef.current.presence.leave();
channelRef.current.unsubscribe();
}
if (realtime) {
realtime.disconnect();
}
};
}, [roomId, currentUser]);
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
setInputValue(e.target.value);
// Send typing indicator
if (channelRef.current) {
channelRef.current.typing.start({
user_id: currentUser.user_id,
user_name: currentUser.name
});
// Clear previous timeout
if (typingTimeoutRef.current) {
clearTimeout(typingTimeoutRef.current);
}
// Stop typing after 2 seconds of inactivity
typingTimeoutRef.current = setTimeout(() => {
channelRef.current?.typing.stop();
}, 2000);
}
};
const sendMessage = () => {
if (!inputValue.trim() || !channelRef.current) return;
channelRef.current.send({
type: 'message',
data: {
id: `msg_${Date.now()}`,
user_id: currentUser.user_id,
user_name: currentUser.name,
text: inputValue,
created_at: new Date().toISOString()
}
});
channelRef.current.typing.stop();
setInputValue('');
};
return (
<div className="chat-room">
<div className="chat-header">
<span className={`status ${isConnected ? 'online' : 'offline'}`} />
<span>{members.length} online</span>
</div>
<div className="members-list">
{members.map(member => (
<div key={member.user_id} className="member">
<img src={member.avatar} alt={member.name} />
<span>{member.name}</span>
</div>
))}
</div>
<div className="messages">
{messages.map(message => (
<div
key={message.id}
className={`message ${message.user_id === currentUser.user_id ? 'own' : ''}`}
>
<strong>{message.user_name}</strong>
<p>{message.text}</p>
<small>{new Date(message.created_at).toLocaleTimeString()}</small>
</div>
))}
</div>
{typingUsers.length > 0 && (
<div className="typing-indicator">
{typingUsers.join(', ')} {typingUsers.length === 1 ? 'is' : 'are'} typing...
</div>
)}
<div className="input-area">
<input
type="text"
value={inputValue}
onChange={handleInputChange}
onKeyPress={(e) => e.key === 'Enter' && sendMessage()}
placeholder="Type a message..."
/>
<button onClick={sendMessage}>Send</button>
</div>
</div>
);
}
React Video Call Component
import React, { useEffect, useRef, useState } from 'react';
import { create23BlocksClient } from '@23blocks/sdk';
interface Participant {
id: string;
user_id: string;
name: string;
stream: MediaStream;
audio: boolean;
video: boolean;
}
const client = create23BlocksClient({
urls: { realtime: 'https://conversations.api.us.23blocks.com' },
appId: process.env.REACT_APP_APP_ID!,
apiKey: process.env.REACT_APP_API_KEY!,
});
export function VideoRoom({ roomId, currentUser }: { roomId: string; currentUser: any }) {
const [participants, setParticipants] = useState<Participant[]>([]);
const [isAudioEnabled, setIsAudioEnabled] = useState(true);
const [isVideoEnabled, setIsVideoEnabled] = useState(true);
const [isScreenSharing, setIsScreenSharing] = useState(false);
const localVideoRef = useRef<HTMLVideoElement>(null);
const roomRef = useRef<any>(null);
useEffect(() => {
async function joinRoom() {
// Join WebRTC room
const room = await client.realtime.webrtc.join({
room_id: roomId,
audio: true,
video: true,
user_data: {
user_id: currentUser.user_id,
name: currentUser.name
}
});
roomRef.current = room;
// Set local video
if (localVideoRef.current && room.localStream) {
localVideoRef.current.srcObject = room.localStream;
}
// Handle participant events
room.on('participant:joined', (participant: Participant) => {
setParticipants(prev => [...prev, participant]);
});
room.on('participant:left', (participant: Participant) => {
setParticipants(prev => prev.filter(p => p.id !== participant.id));
});
room.on('participant:stream', (participant: Participant) => {
setParticipants(prev =>
prev.map(p => p.id === participant.id ? { ...p, stream: participant.stream } : p)
);
});
room.on('participant:audio', (participant: Participant) => {
setParticipants(prev =>
prev.map(p => p.id === participant.id ? { ...p, audio: participant.audio } : p)
);
});
room.on('participant:video', (participant: Participant) => {
setParticipants(prev =>
prev.map(p => p.id === participant.id ? { ...p, video: participant.video } : p)
);
});
}
joinRoom();
return () => {
if (roomRef.current) {
roomRef.current.leave();
}
};
}, [roomId, currentUser]);
const toggleAudio = () => {
if (roomRef.current) {
roomRef.current.audio.toggle();
setIsAudioEnabled(!isAudioEnabled);
}
};
const toggleVideo = () => {
if (roomRef.current) {
roomRef.current.video.toggle();
setIsVideoEnabled(!isVideoEnabled);
}
};
const toggleScreenShare = async () => {
if (roomRef.current) {
if (isScreenSharing) {
roomRef.current.screen.stop();
} else {
await roomRef.current.screen.share();
}
setIsScreenSharing(!isScreenSharing);
}
};
const leaveRoom = () => {
if (roomRef.current) {
roomRef.current.leave();
}
};
return (
<div className="video-room">
<div className="video-grid">
{/* Local video */}
<div className="video-tile local">
<video
ref={localVideoRef}
autoPlay
muted
playsInline
/>
<span className="name">{currentUser.name} (You)</span>
</div>
{/* Remote participants */}
{participants.map(participant => (
<ParticipantVideo
key={participant.id}
participant={participant}
/>
))}
</div>
<div className="controls">
<button
onClick={toggleAudio}
className={isAudioEnabled ? 'active' : 'muted'}
>
{isAudioEnabled ? 'Mute' : 'Unmute'}
</button>
<button
onClick={toggleVideo}
className={isVideoEnabled ? 'active' : 'disabled'}
>
{isVideoEnabled ? 'Stop Video' : 'Start Video'}
</button>
<button onClick={toggleScreenShare}>
{isScreenSharing ? 'Stop Share' : 'Share Screen'}
</button>
<button onClick={leaveRoom} className="leave">
Leave
</button>
</div>
</div>
);
}
function ParticipantVideo({ participant }: { participant: Participant }) {
const videoRef = useRef<HTMLVideoElement>(null);
useEffect(() => {
if (videoRef.current && participant.stream) {
videoRef.current.srcObject = participant.stream;
}
}, [participant.stream]);
return (
<div className="video-tile">
<video
ref={videoRef}
autoPlay
playsInline
/>
<span className="name">{participant.name}</span>
{!participant.audio && <span className="muted-icon">🔇</span>}
</div>
);
}
Conversations API — AI Summaries & Digest
The Conversations API adds AI-powered features on top of real-time messaging. Summaries and digests are generated by the Jarvis AI Block using LLM analysis.
Users are automatically registered with the Jarvis AI system on their first summary or digest request. No separate onboarding step is needed — just call the endpoint and it works.
AI Conversation Summary
Get a structured AI summary of a single conversation.
GET /conversations/:conversation_uid/summary
Headers:
Authorization: Bearer <user-jwt>
X-App-Id: your-app-id
X-Api-Key: your-api-key
Response:
{
"data": {
"id": "conv_abc123",
"type": "conversation_summary",
"attributes": {
"overall_summary": "Discussion about Q3 product roadmap with focus on AI features...",
"categories": ["product-planning", "ai-features", "timeline"],
"action_items": [
"Finalize AI summary API spec by Friday",
"Schedule design review for digest UI"
],
"key_decisions": [
"AI features prioritized for Q3 launch",
"Team agreed on incremental rollout strategy"
],
"sentiment": "positive",
"stats": {
"total_messages": 47,
"participants": 4,
"time_span": "2h 15m"
},
"generated_at": "2026-05-20T10:30:00Z",
"cached": false
}
}
}
How it works:
- Incremental processing — only new messages since the last summary are sent to the LLM
- Built-in caching with smart invalidation and staleness detection
- 60-second rate limiting per conversation
- Schema validation of all LLM outputs with automatic retry
- Graceful degradation — stale cache returned if Jarvis is temporarily unavailable
AI Cross-Conversation Digest
Aggregate all unread conversations into a single AI-generated digest. One API call gives admins or users a complete picture of all active conversations.
GET /users/:user_uid/conversations/summary
Query Parameters:
| Parameter | Type | Description |
|---|---|---|
custom[key] | string | Filter by JSONB payload field (e.g., ?custom[project]=alpha) |
Response:
{
"data": {
"id": "digest_xyz789",
"type": "conversation_digest",
"attributes": {
"conversations_found": 12,
"digest": {
"summary": "12 active conversations across 3 projects. Key updates: deployment approved for Project Alpha, design review pending for Project Beta...",
"categories": {
"urgent": ["conv_001", "conv_005"],
"pending": ["conv_002", "conv_008"],
"on_track": ["conv_003", "conv_004", "conv_006"],
"stale": ["conv_007"]
},
"action_items": [
"Approve Project Beta design mockups",
"Assign bug #4521 to backend team",
"Follow up on stale casting inquiry"
],
"stats": {
"total_unread_messages": 89,
"conversations_with_unread": 8,
"oldest_unread": "2026-05-19T08:00:00Z"
}
},
"meta": {
"tokens_used": 2340,
"generated_at": "2026-05-20T10:30:00Z",
"cache_status": "fresh"
}
}
}
}
The digest categorizes conversations by urgency: urgent, pending, on_track, and stale.
How it works:
- Role-aware grouping across conversations
- AI categorization by urgency (urgent/pending/on_track/stale) with prioritized action items
- Progressive warming — handles large inboxes without timeouts (caps 10 fresh per request)
- Per-tenant configuration:
recency_days,max_fresh_per_request,unread_only - Per-tenant prompt configuration via company settings (customizable LLM prompts)
- JSONB payload filtering for scoping digests to specific projects or tags
- Graceful degradation — stale cache returned if Jarvis is temporarily unavailable
Use Cases
| Industry | How AI Summaries Help |
|---|---|
| Customer Support | Categorize conversations by urgency, extract action items for agents |
| Education | Summarize student-teacher conversations with follow-up reminders |
| Marketplace | Track talent casting inquiries across projects with status categories |
| AI Agent Platforms | Feed conversation digests to downstream AI agents for automated triage |
Unread Summary
Get unread conversation counts with grouping and filtering options.
GET /users/:user_uid/unread-summary
Query Parameters:
| Parameter | Type | Description |
|---|---|---|
has_unread | boolean | true (default) returns only conversations with unread messages. false returns all conversations with counts. |
Response:
{
"data": {
"id": "usr_abc123",
"type": "unread_summary",
"attributes": {
"total_unread": 89,
"conversations": [
{
"conversation_uid": "conv_001",
"unread_count": 15,
"last_message_at": "2026-05-20T10:25:00Z"
},
{
"conversation_uid": "conv_002",
"unread_count": 7,
"last_message_at": "2026-05-20T09:45:00Z"
}
]
}
}
}
Key improvements:
- Per-conversation unread counts (previously group-level only)
- Role-aware unread tracking via
notify_roleson conversations - Flexible filtering with
has_unreadparameter
Auto-Registration with Jarvis
When a user calls a summary or digest endpoint for the first time, the system automatically registers them with the Jarvis AI Block. This is transparent — consumers don't need to call any registration endpoint or add any onboarding steps.
The auto-registration uses a register-once pattern with transparent retry. Subsequent requests skip registration entirely.
Rate Limits
| Plan | Connections | Messages/sec | Channels | History Retention |
|---|---|---|---|---|
| Free | 100 | 50 | 10 | 24 hours |
| Starter | 5,000 | 500 | 100 | 7 days |
| Pro | 50,000 | 5,000 | 1,000 | 30 days |
| Enterprise | Unlimited | Custom | Unlimited | Custom |
Best Practices
Optimize Connection Handling
// Use a singleton for real-time connection
class RealtimeService {
private static instance: any = null;
static async getInstance(userId: string) {
if (!this.instance) {
const { token } = await client.realtime.getToken({ user_id: userId });
this.instance = await client.realtime.connect({ token });
// Handle reconnection
this.instance.on('disconnected', async () => {
// Token might be expired, get new one
const { token: newToken } = await client.realtime.getToken({ user_id: userId });
await this.instance.reconnect({ token: newToken });
});
}
return this.instance;
}
}
Handle Offline Messages
// Queue messages when offline
const messageQueue: any[] = [];
realtime.on('disconnected', () => {
// Switch to queueing mode
});
realtime.on('connected', async () => {
// Send queued messages
for (const msg of messageQueue) {
await channel.send(msg);
}
messageQueue.length = 0;
});
Implement Typing Indicators Efficiently
// Debounce typing indicators
let typingTimeout: NodeJS.Timeout | null = null;
function handleTyping() {
if (!typingTimeout) {
channel.typing.start();
}
if (typingTimeout) {
clearTimeout(typingTimeout);
}
typingTimeout = setTimeout(() => {
channel.typing.stop();
typingTimeout = null;
}, 2000);
}
Changelog
v3.0.0
- Added AI Conversation Summaries (LLM-powered, incremental, cached)
- Added AI Cross-Conversation Digest (aggregate unread into single digest)
- Added Smart Unread Tracking (per-conversation counts, role-aware)
- Added auto-registration with Jarvis AI system
- Added JSONB payload filtering for digest scoping
v2.1.0
- Added WebRTC screen sharing
- Improved reconnection logic
- Added message search API
v2.0.0
- Complete API redesign
- Added WebRTC support
- Added typing indicators and read receipts
- Enhanced presence tracking
v1.0.0
- Initial release with WebSocket channels and basic presence