@happyvertical/smrt-chat

Chat rooms, threads, and agent sessions with app-controlled tool whitelisting and a unified ChatMessage model for users and agents.

v0.29.34RoomsThreadsAgent Sessions

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

bash
npm install @happyvertical/smrt-chat

Quick Start

typescript
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

typescript
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.

typescript
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

typescript
class ChatParticipant extends SmrtObject {
  roomId: string
  profileId: string
  role: 'owner' | 'admin' | 'member' | 'viewer'
  onlineStatus: string
  lastReadMessageId?: string
  isMuted: boolean
}

ChatThread

typescript
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.

typescript
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.

typescript
// 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 ChatService facade 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 allowedTools per session — the framework enforces it fail-closed on agent replies
  • Use getOrCreateDM() for direct message rooms (idempotent)

DON'Ts

  • Don't use the context field for session memory — it's reserved for slug scoping. Use sessionContext.
  • Don't skip session expiry checks (expiresAt or 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 — ChatRoom requires tenant scoping
  • Don't create agent rooms manually — use createAgentSession()

Related Modules