SMS Composer
Enable solvers to send manual SMS messages to customers during support sessions
SMS Composer
The SMS Composer enables solvers to send text messages directly to customers during active support sessions. This feature supports the PRD's vision of omnichannel communication—solvers pick the right tool for the job, whether that's chat, voice, or SMS.
SMS is particularly valuable for reaching customers who aren't actively in the app, delivering clickable links (screen share, payment), and providing a persistent record of important information the customer can reference later.
How It Works
The SMS system consists of three layers: a presentational UI component, a connected wrapper that integrates with the backend, and a tRPC router that handles the actual message sending with rate limiting.
UI Component
The SMSComposer component provides a textarea-based interface with character counting, template selection, and rate limit awareness.
export function SMSComposer({
onSend,
recipientPhone,
templates,
disabled = false,
remainingSms,
maxSms,
className,
}: SMSComposerProps) {
const charCount = message.length;
const segments = calculateSegments(charCount);
const isRateLimited = remainingSms !== undefined && remainingSms <= 0;
const canSend =
message.trim().length > 0 && !isSending && !disabled && !isRateLimited;
// ...
}The component tracks the standard 160-character SMS limit and calculates multi-part message segments when longer messages are composed. Visual indicators warn users as they approach or exceed limits, and the send button is disabled when rate limits are reached.
Connected Component
The SessionSMSComposer wraps the presentational component and connects it to the tRPC backend. It handles fetching the remaining SMS count, populating template variables with session data, and calling the send mutation.
export function SessionSMSComposer({
sessionId,
customerPhone,
customerName = "Customer",
screenShareLink,
paymentLink,
disabled = false,
className,
}: SessionSMSComposerProps) {
// Fetch remaining SMS count
const { data: smsCount } = trpc.sms.getRemainingCount.useQuery(
{ sessionId },
{ refetchInterval: 30000 },
);
// Send SMS mutation
const sendSms = trpc.sms.send.useMutation();
// Convert templates with filled variables
const templates = useMemo(
() => convertTemplates(getSelectableTemplates(), templateVariables),
[templateVariables],
);
// ...
}This separation allows the core UI to be tested in isolation while the connected version handles all the data fetching and mutation logic.
Message Templates
Solvers frequently send similar messages—greeting customers, sharing screen share links, or reminding about payment. Pre-defined templates save time and ensure consistent, professional communication.
export const smsTemplates: SMSTemplate[] = [
{
id: "greeting",
name: "Greeting",
content:
"Hi {{customerName}}, this is your SavvySolve helper. I'm ready to assist you with your tech issue.",
variables: ["customerName"],
},
{
id: "screen-share",
name: "Screen Share Link",
content:
"To help you better, please click this link to share your screen with me: {{screenShareLink}}",
variables: ["screenShareLink"],
},
{
id: "payment-reminder",
name: "Payment Reminder",
content:
"Your session is complete! Please complete payment: {{paymentLink}}. Thank you for using SavvySolve!",
variables: ["paymentLink"],
},
// ... more templates
];Templates use {{variable}} placeholders that get replaced with actual session data when selected. If a variable isn't available (e.g., no screen share link generated yet), a placeholder like [screen share link] is shown so the solver knows to generate the link first.
The fillTemplate function handles variable substitution:
export function fillTemplate(
template: string,
variables: Record<string, string>,
): string {
let result = template;
for (const [key, value] of Object.entries(variables)) {
const placeholder = new RegExp(`\\{\\{${key}\\}\\}`, "g");
result = result.replace(placeholder, value);
}
return result;
}API Reference
The SMS router exposes three procedures for sending messages, retrieving history, and checking rate limits.
sms.send
Sends an SMS message to the customer associated with a session.
send: protectedProcedure
.input(
z.object({
sessionId: z.string().uuid(),
message: z.string().min(1).max(1000),
}),
)
.mutation(async ({ input }) => {
// Verify session exists and get customer phone
// Check rate limit (max 10 per session)
// Send via Telnyx and log to sms_logs
return { success: true, messageId: result.messageId };
})The procedure validates the session exists, enforces the rate limit, retrieves the customer phone from the associated ticket, and sends via the Telnyx SMS service.
sms.getHistory
Retrieves SMS message history for a session, useful for displaying conversation context.
getHistory: protectedProcedure
.input(
z.object({
sessionId: z.string().uuid(),
limit: z.number().min(1).max(100).default(50),
}),
)
.query(async ({ input }) => {
// Returns messages in chronological order (oldest first)
})sms.getRemainingCount
Returns the current SMS usage for rate limiting display in the UI.
getRemainingCount: protectedProcedure
.input(z.object({ sessionId: z.string().uuid() }))
.query(async ({ input }) => {
return {
sent: 3, // Messages sent so far
remaining: 7, // Messages still available
max: 10, // Maximum per session
};
})Rate Limiting
Each session is limited to 10 outbound SMS messages to prevent abuse and control costs. The limit is enforced server-side by counting existing sms_logs entries with direction: "outbound" for the session.
The UI displays the remaining count and disables the composer when the limit is reached:
- 8+ remaining: Normal muted text
- 1-3 remaining: Amber warning color
- 0 remaining: Red text, composer disabled
Character Counting
SMS messages have a standard 160-character limit for single messages. Longer messages are split into multiple segments of 153 characters each (7 characters reserved for concatenation headers).
The composer shows:
- Current character count vs 160 limit
- Amber color when approaching limit (140+ characters)
- Red color when over limit
- Segment count for multi-part messages (e.g., "2 messages")
Testing Strategy
The SMS feature has comprehensive test coverage across all three layers:
| File | Tests | Coverage |
|---|---|---|
components/session/SMSComposer.test.tsx | 28 | UI behavior, character counting, templates |
components/session/SessionSMSComposer.test.tsx | 11 | tRPC integration, variable filling |
server/routers/sms.test.ts | 12 | API validation, rate limiting, error handling |
lib/templates/sms.test.ts | 15 | Template filling, variable replacement |
Key test scenarios include:
- Character counter accuracy and color changes
- Template selection and variable population
- Rate limit enforcement (both UI and API)
- Error handling for missing sessions or phone numbers
- Multi-part message segment calculation
Usage Example
To add the SMS composer to a session interface:
import { SessionSMSComposer } from "@/components/session/SessionSMSComposer";
function SessionWorkspace({ session }) {
return (
<SessionSMSComposer
sessionId={session.id}
customerPhone={session.ticket.customerInfo.phone}
customerName={session.ticket.customerInfo.name}
screenShareLink={session.screenShareLink}
paymentLink={session.paymentLink}
/>
);
}The component automatically fetches rate limit data, populates templates with the provided session context, and handles sending via the tRPC mutation.