Chat Add-on
Real-time multi-room messaging backed by the immutable strand. Agents ask questions, humans answer — every message is a cryptographically signed record with the same integrity guarantees as your data.
SAPIX_CHAT_ENABLED=trueNo extra dependencies. SSE streaming. Messages are strand records — permanent, signed, auditable.
Enable
SAPIX_CHAT_ENABLED=true
Rooms
Rooms are persistent named channels. Create a room once — it persists across agent restarts. Room metadata (name, description, members) is stored in the GraphIndex meta column family.
// Create a room
const r = await fetch("http://localhost:7475/v1/chat/rooms", {
method: "POST",
headers: { "Content-Type": "application/json", Authorization: `Bearer ${apiKey}` },
body: JSON.stringify({
name: "dba-alerts",
description: "DBA Agent operational alerts and approvals",
creator_id: "dba-agent",
}),
});
const { room_id } = await r.json();
// List all rooms
const rooms = await fetch("http://localhost:7475/v1/chat/rooms", {
headers: { Authorization: `Bearer ${apiKey}` },
});
// Add a member
await fetch(`http://localhost:7475/v1/chat/rooms/${room_id}/members`, {
method: "POST",
headers: { "Content-Type": "application/json", Authorization: `Bearer ${apiKey}` },
body: JSON.stringify({ user_id: "usr_operator_123" }),
});Messages
Messages are written directly to the strand — the same append-only, cryptographically linked record store used for all agent data. This means every message is permanently retained, tamper-evident, and exportable for compliance purposes.
// Send a message from an agent
const msg = await fetch(`http://localhost:7475/v1/chat/rooms/${room_id}/messages`, {
method: "POST",
headers: { "Content-Type": "application/json", Authorization: `Bearer ${apiKey}` },
body: JSON.stringify({
sender_id: "dba-agent",
body: "Table bloat on `users` is at 78%. Should I schedule a VACUUM now? Reply YES or NO.",
}),
});
const message = await msg.json();
// { message_id, room_id, sender_id, body, created_at_secs }
// Retrieve recent messages (newest first)
const history = await fetch(
`http://localhost:7475/v1/chat/rooms/${room_id}/messages?limit=50`,
{ headers: { Authorization: `Bearer ${apiKey}` } },
);
const { messages } = await history.json();SSE Stream — Real-Time Updates
Subscribe to a room's stream using Server-Sent Events. The stream delivers new messages within milliseconds of a write. Keep-alive pings are sent every 15 seconds to prevent connection timeout.
// Browser — EventSource
const es = new EventSource(`http://localhost:7475/v1/chat/rooms/${room_id}/stream`);
// Note: EventSource doesn't support custom headers in some browsers.
// Use a server-side proxy or pass apiKey as a query parameter for authenticated streams.
es.onmessage = (evt) => {
const { room_id, message } = JSON.parse(evt.data);
console.log(`[${message.sender_id}] ${message.body}`);
// Update your UI here
};
es.onerror = () => es.close(); // Reconnect logic optional
import { EventSource } from "eventsource"; // npm install eventsource
const es = new EventSource(
`http://localhost:7475/v1/chat/rooms/${room_id}/stream`,
{ headers: { Authorization: `Bearer ${apiKey}` } },
);
es.onmessage = async (evt) => {
const { message } = JSON.parse(evt.data);
// Process human response to agent question
if (message.body.toUpperCase() === "YES") {
await scheduleVacuum();
}
};SSE event format
// Each SSE event data field contains:
{
"room_id": "room_a3f9...",
"message": {
"message_id": "msg_b2c1...",
"room_id": "room_a3f9...",
"sender_id": "dba-agent",
"body": "Table bloat at 78%. Proceed?",
"created_at_secs": 1749427260
}
}Agent Integration Pattern
The canonical pattern for human-in-the-loop agent workflows: the agent sends a message, opens a stream subscription, and waits for a response before proceeding.
import { EventSource } from "eventsource";
async function requestApproval(
roomId: string,
question: string,
timeoutMs = 300_000, // 5 minute timeout
): Promise<boolean> {
// 1. Open the stream first (so we don't miss the response)
return new Promise((resolve) => {
const es = new EventSource(
`http://localhost:7475/v1/chat/rooms/${roomId}/stream`,
{ headers: { Authorization: `Bearer ${apiKey}` } },
);
const timer = setTimeout(() => {
es.close();
resolve(false); // Timeout — default deny
}, timeoutMs);
es.onmessage = (evt) => {
const { message } = JSON.parse(evt.data);
const answer = message.body.trim().toUpperCase();
if (answer === "YES" || answer === "APPROVE") {
clearTimeout(timer);
es.close();
resolve(true);
} else if (answer === "NO" || answer === "DENY") {
clearTimeout(timer);
es.close();
resolve(false);
}
};
// 2. After opening stream, send the question
fetch(`http://localhost:7475/v1/chat/rooms/${roomId}/messages`, {
method: "POST",
headers: { "Content-Type": "application/json", Authorization: `Bearer ${apiKey}` },
body: JSON.stringify({ sender_id: "dba-agent", body: question }),
});
});
}
// Usage
const approved = await requestApproval(
"dba-alerts",
"Index on users.email will reduce query time by 85%. Apply? Reply YES or NO.",
);
if (approved) await applyIndex();HTTP API Reference
| Method | Path | Description |
|---|---|---|
| POST | /v1/chat/rooms | Create a room |
| GET | /v1/chat/rooms | List all rooms |
| GET | /v1/chat/rooms/:id | Get a room |
| DELETE | /v1/chat/rooms/:id | Delete a room (strand tombstone written) |
| POST | /v1/chat/rooms/:id/members | Add a member |
| DELETE | /v1/chat/rooms/:id/members/:user_id | Remove a member |
| POST | /v1/chat/rooms/:id/messages | Send a message (written to strand + broadcast) |
| GET | /v1/chat/rooms/:id/messages?limit=50 | Get recent messages |
| GET | /v1/chat/rooms/:id/stream | SSE stream — real-time messages for this room |
Strand audit records
| Payload type | Trigger |
|---|---|
| chat/message | Every send_message() call |
| chat/room_deleted | Room deletion |
Configuration
| Environment variable | Default | Description |
|---|---|---|
| SAPIX_CHAT_ENABLED | false | Enable the chat add-on |
Known Limitations
- Message retrieval is O(n) in strand size.
GET /messagesscans the strand for records matching the room. For high-write agents, run a dedicated chat agent with a separate data directory. - Broadcast capacity is 1024. Slow SSE clients that fall more than 1024 messages behind will miss events. Use
GET /messagesto catch up. - No per-room access control. Any API key can read any room. Per-room ACL is planned via the Policy Engine.
- Messages are immutable. The strand is append-only — messages cannot be edited or deleted. A tombstone pattern for individual messages is planned.
- SSE auth in browsers. The browser
EventSourceAPI does not support custom headers in all implementations. Use a server-side proxy or query-parameter token for authenticated browser streams.