@happyvertical/smrt-chat
Chat rooms, threads, and agent sessions with app-controlled tool whitelisting and a unified ChatMessage model for users and agents.
Overview
smrt-chat provides multi-tenant chat infrastructure with public / private
rooms, direct messages, threaded conversations, and AI agent sessions with configurable tool
access. ChatMessage is shared by users and agents — there is no separate "agent
message" type. The allowedTools whitelist is app-controlled, and the framework enforces
it fail-closed when an agent reply emits a tool call (an empty or unparseable whitelist permits
no tools).
Installation
npm install @happyvertical/smrt-chatQuick Start
import { ChatService } from '@happyvertical/smrt-chat';
const chat = await ChatService.create({
persistence: { type: 'sql', url: 'chat.db' },
});
// Create a public room. actorProfileId is the authenticated principal
// the route injects; that actor becomes the owner participant.
const room = await chat.createRoom({
tenantId: 'tenant-1',
name: 'General',
roomType: 'public',
actorProfileId: 'profile-1',
});
// Send a user message. The message is always authored as actorProfileId
// with role 'user' — the caller cannot supply a senderProfileId or role.
const message = await chat.sendMessage({
tenantId: 'tenant-1',
roomId: room.id,
actorProfileId: 'profile-1',
content: 'Hello, world!',
});
// Start a threaded conversation
const thread = await chat.startThread({
tenantId: 'tenant-1',
roomId: room.id,
actorProfileId: 'profile-1',
rootMessageId: message.id,
title: 'Follow-up discussion',
});
// Reply within the thread
await chat.sendMessage({
tenantId: 'tenant-1',
roomId: room.id,
actorProfileId: 'profile-2',
content: 'Great point!',
threadId: thread.id,
});
// Get or create a DM room between two profiles. The acting caller must be
// one of the two DM participants.
const dmRoom = await chat.getOrCreateDM({
tenantId: 'tenant-1',
actorProfileId: 'profile-1',
profileId1: 'profile-1',
profileId2: 'profile-2',
});Core Models
ChatRoom
class ChatRoom extends SmrtObject {
name: string
roomType: 'public' | 'private' | 'dm' | 'agent'
status: string
topic?: string
maxParticipants?: number
lastMessageAt?: Date
// Tenant-scoped (required)
}ChatMessage — unified for users and agents
A single model carries both human and agent traffic. The role + messageType pair lets clients render and filter without branching on entity type. toolCallData holds JSON for tool calls and results when the message is part of an agent
turn.
class ChatMessage extends SmrtObject {
roomId: string
senderProfileId: string
content: string
role: 'user' | 'assistant' | 'system' | 'tool'
messageType: 'text' | 'system' | 'action' | 'file' | 'tool_call' | 'tool_result'
threadId?: string
replyToMessageId?: string
toolCallData?: string // JSON for tool interactions
}ChatParticipant
class ChatParticipant extends SmrtObject {
roomId: string
profileId: string
role: 'owner' | 'admin' | 'member' | 'viewer'
onlineStatus: string
lastReadMessageId?: string
isMuted: boolean
}ChatThread
class ChatThread extends SmrtObject {
roomId: string
rootMessageId: string
isResolved: boolean
messageCount: number
}AgentSession
The agentId is intentionally a plain string reference, not a foreign key, so chat
is decoupled from any specific agent registry. Budgets are flexible: an agent can be limited
by wall-clock (expiresAt), token spend (maxTokens), or message count
(maxMessages) — singly or in combination.
class AgentSession extends SmrtObject {
agentId: string // String ref (not FK)
participantProfileId: string // Profile cross-package ref
chatRoomId: string | null // FK to the linked agent room
status: 'active' | 'closed' | 'expired'
allowedTools: string // JSON array, default '[]' (app-controlled whitelist)
sessionContext: string // JSON, default '{}' (multi-turn memory)
systemPrompt: string
messageCount: number
totalTokensUsed: number
maxTokens: number // 0 = unlimited
maxMessages: number // 0 = unlimited
lastMessageAt: Date | null
expiresAt: Date | null
closedAt: Date | null
isActive(): boolean
isToolAllowed(toolName: string): boolean
getAllowedTools(): string[]
getSessionContext(): Record<string, unknown>
setSessionContext(ctx: Record<string, unknown>): void
updateSessionContext(updates: Record<string, unknown>): Promise<void>
expire(): Promise<void>
}App-controlled tool whitelisting
allowedTools is a simple JSON array your app populates on the session. The
framework owns enforcement: agent-authored replies go through the internal sendAgentReply() path (reachable only via the @happyvertical/smrt-chat/internal/agent-runtime subpath by trusted in-process
agent-runtime code), which checks each tool / tool_call message
against the whitelist fail-closed before emitting it. An empty or unparseable whitelist
permits no tools. Your app decides which tools an agent may use; SMRT guarantees an agent
cannot emit a tool message outside that list.
// Create an agent session (auto-creates an agent-type room with maxParticipants=2).
// actorProfileId becomes the owning session participant — you cannot supply a
// participantProfileId to open a session on another profile's behalf.
const { session, room } = await chat.createAgentSession({
tenantId: 'tenant-1',
agentId: 'agent-summarizer',
actorProfileId: 'profile-1',
allowedTools: ['web-search', 'summarize'],
systemPrompt: 'You are a research assistant.',
maxMessages: 100,
});
// Send a USER message within the agent session. The message is always
// authored as the session participant — the caller cannot supply a
// senderProfileId or role (no impersonating the agent from a route).
await chat.sendAgentUserMessage({
tenantId: 'tenant-1',
agentSessionId: session.id,
actorProfileId: 'profile-1',
content: 'Summarize the latest news',
});
// Always gate sends on isActive() -- expiresAt OR token/message limits
if (session.isActive()) {
// safe to dispatch
}
// Tool whitelisting is enforced by the framework: the internal agent-runtime
// reply path rejects any tool not in allowedTools, fail-closed. You can also
// inspect the list directly when configuring the agent.
if (session.isToolAllowed(requestedTool)) {
// the agent runtime is permitted to emit this tool call
}Best Practices
DOs
- Use
ChatServicefacade for room creation and messaging — it auto-creates participants and agent rooms - Check
session.isActive()before sending agent messages - Use
getSessionContext()/updateSessionContext()for multi-turn memory - Populate
allowedToolsper session — the framework enforces it fail-closed on agent replies - Use
getOrCreateDM()for direct message rooms (idempotent)
DON'Ts
- Don't use the
contextfield for session memory — it's reserved for slug scoping. UsesessionContext. - Don't skip session expiry checks (
expiresAtor budget limits) - Don't try to author agent (
assistant/tool) messages from a route — only the internal agent-runtime path may, via@happyvertical/smrt-chat/internal/agent-runtime - Don't forget tenant context —
ChatRoomrequires tenant scoping - Don't create agent rooms manually — use
createAgentSession()