SavvySolve Docs

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.

components/session/SMSComposer.tsx
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.

components/session/SessionSMSComposer.tsx
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.

lib/templates/sms.ts
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:

lib/templates/sms.ts
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.

server/routers/sms.ts
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:

FileTestsCoverage
components/session/SMSComposer.test.tsx28UI behavior, character counting, templates
components/session/SessionSMSComposer.test.tsx11tRPC integration, variable filling
server/routers/sms.test.ts12API validation, rate limiting, error handling
lib/templates/sms.test.ts15Template 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.

On this page