supportmate
v0.0.10
Published
Founder-led customer support OS — Telegram bots, Discord cross-post, PWA push, multi-thread, status tracking. Built on Convex.
Maintainers
Readme
supportmate
Founder-led customer support OS. A Convex Component that gives you an in-app messenger, a Telegram bot bridge, Discord cross-post, PWA push, a public roadmap, and a changelog feed — all installed with one
app.use(...)call.
npm install supportmate
# or
bun add supportmateFeatures
- In-app messenger — multi-thread, status-tracked, ready to render in any framework
- Telegram bot bridge — every customer gets their own forum topic; you reply by typing
- Discord cross-post — every broadcast and roadmap event lands in your Discord channel
- PWA push notifications — OS-level alerts via web-push (Chrome / Safari / Firefox)
- Public roadmap — Canny-style voting, comments, status pills (submitted → reviewing → in-progress → shipped → closed)
- Changelog / broadcasts — WhatsApp-Channel-style rich update cards with reactions
- Saved replies — pre-written canned responses fired from Telegram or admin UI
- Ticket subscriptions — users get notified when their bug or feature request changes status
Why supportmate
Modern support tools (Plain, Crisp, Intercom) start at ~$89/seat and were built for support teams. The interface assumes you have a queue, a roster, an SLA dashboard. None of that matters when you're a solo founder running customer service from your phone.
supportmate is the opposite: one operator, one Telegram inbox, one source of truth in your own Convex deployment. You ship it as a Convex Component into your existing app, wrap a few mutations with your own auth, and you have a support loop that scales from your first user to your first thousand without adding another bill.
Setup
Step 1 — Install the component
// convex/convex.config.ts
import { defineApp } from "convex/server";
import supportmate from "supportmate/convex.config";
const app = defineApp();
app.use(supportmate);
export default app;After running npx convex dev once, the component's API is reachable at
components.supportmate.<file>.<func> from your app's Convex functions.
Step 2 — Wrap component functions with your auth
The component never reads process.env and never knows who the user is — your
app is the source of truth for auth. Write thin wrappers in convex/support.ts
that resolve the userId via your auth strategy and forward to the component.
// convex/support.ts
import { v } from "convex/values";
import { mutation, query } from "./_generated/server";
import { components } from "./_generated/api";
import { getAuthUserId } from "@convex-dev/auth/server"; // or any auth strategy
const APP_KEY = "myapp";
export const sendMessage = mutation({
args: { body: v.string(), threadId: v.optional(v.id("threads")) },
handler: async (ctx, args) => {
const userId = await getAuthUserId(ctx);
if (!userId) throw new Error("Not signed in");
return ctx.runMutation(components.supportmate.threads.sendMessage, {
userId,
app: APP_KEY,
body: args.body,
threadId: args.threadId,
});
},
});
export const submitBugReport = mutation({
args: {
title: v.string(),
description: v.optional(v.string()),
screenshotId: v.optional(v.string()),
context: v.optional(v.object({ page: v.string(), browser: v.string() })),
},
handler: async (ctx, args) => {
const userId = await getAuthUserId(ctx);
if (!userId) throw new Error("Not signed in");
return ctx.runMutation(components.supportmate.tickets.submitBugReport, {
userId,
app: APP_KEY,
...args,
});
},
});
export const submitFeatureRequest = mutation({
args: {
title: v.string(),
description: v.optional(v.string()),
category: v.optional(v.string()),
},
handler: async (ctx, args) => {
const userId = await getAuthUserId(ctx);
if (!userId) throw new Error("Not signed in");
return ctx.runMutation(components.supportmate.tickets.submitFeatureRequest, {
userId,
app: APP_KEY,
...args,
});
},
});
export const getMyUnreadCounts = query({
args: {},
handler: async (ctx) => {
const userId = await getAuthUserId(ctx);
if (!userId) return { broadcasts: 0, tickets: 0 };
return ctx.runQuery(components.supportmate.panelReads.getMyUnreadCounts, {
userId,
app: APP_KEY,
});
},
});
export const voteForTicket = mutation({
args: { ticketId: v.string() },
handler: async (ctx, args) => {
const userId = await getAuthUserId(ctx);
if (!userId) throw new Error("Not signed in");
return ctx.runMutation(components.supportmate.roadmap.voteForTicket, {
userId,
ticketId: args.ticketId,
});
},
});
export const addComment = mutation({
args: { ticketId: v.string(), body: v.string() },
handler: async (ctx, args) => {
const userId = await getAuthUserId(ctx);
if (!userId) throw new Error("Not signed in");
return ctx.runMutation(components.supportmate.roadmap.addComment, {
userId,
ticketId: args.ticketId,
body: args.body,
});
},
});
export const subscribePush = mutation({
args: {
endpoint: v.string(),
p256dh: v.string(),
auth: v.string(),
userAgent: v.optional(v.string()),
},
handler: async (ctx, args) => {
const userId = await getAuthUserId(ctx);
if (!userId) throw new Error("Not signed in");
return ctx.runMutation(components.supportmate.push.subscribe, {
userId,
app: APP_KEY,
...args,
});
},
});…and so on for the remaining surfaces (getMyThread, listMyThreads,
markRead, clearMyThread, unvoteTicket, subscribeToTicket,
unsubscribeFromTicket, getMyUnreadCounts, markBroadcastsRead,
markTicketsRead, listPublicRoadmap, getRoadmapTicket, listComments,
listRecent). The pattern is always the same — resolve the userId, forward
the args.
Step 3 — Mount the Telegram webhook in convex/http.ts
// convex/http.ts
import { httpRouter } from "convex/server";
import { httpAction } from "./_generated/server";
import { internal } from "./_generated/api";
const http = httpRouter();
http.route({
path: "/telegram/webhook/support",
method: "POST",
handler: httpAction(async (ctx, request) => {
// Telegram webhook secret check
const secret = request.headers.get("x-telegram-bot-api-secret-token");
if (secret !== process.env.TELEGRAM_WEBHOOK_SECRET) {
return new Response("forbidden", { status: 403 });
}
const update = await request.json();
await ctx.runAction(internal.supportActions.handleTelegramUpdate, {
update,
});
return new Response("ok");
}),
});
export default http;Step 4 — Build the bot in a "use node" action
The bot factory is callback-driven. You pass it a callbacks bag of shims that
each call into components.supportmate.* via ctx.runMutation /
ctx.runQuery. This keeps the bot code free of any specific Convex API import
and lets you mix in your own logic per shim.
// convex/supportActions.ts
"use node";
import { v } from "convex/values";
import { internalAction } from "./_generated/server";
import { components } from "./_generated/api";
import { makeSupportBot } from "supportmate/server";
export const handleTelegramUpdate = internalAction({
args: { update: v.any() },
handler: async (ctx, { update }) => {
const bot = makeSupportBot({
token: process.env.TELEGRAM_BOT_TOKEN!,
chatId: process.env.TELEGRAM_CHAT_ID!,
adminBaseUrl: process.env.SITE_URL!,
callbacks: {
// Threads
appendAdminReply: (args) =>
ctx.runMutation(components.supportmate.threads.appendAdminReply, args),
resolveThread: (args) =>
ctx.runMutation(components.supportmate.threads.resolveThread, args),
snoozeThread: (args) =>
ctx.runMutation(components.supportmate.threads.snoozeThread, args),
toggleMute: (args) =>
ctx.runMutation(components.supportmate.threads.toggleMute, args),
findThreadByTelegramAnchor: (args) =>
ctx.runQuery(
components.supportmate.threads.findThreadByTelegramAnchor,
args,
),
getThreadContext: async ({ threadId }) => {
// Compose component thread + your own user lookup.
// Return shape: { user: { email?, name? } | null }
// (See package src/server/telegram/bots/shared.ts for the full type.)
return { user: null };
},
listOpenThreads: () =>
ctx.runQuery(components.supportmate.threads.listOpenThreads, {}),
statsToday: () =>
ctx.runQuery(components.supportmate.threads.statsToday, {}),
// Tickets
updateTicketStatus: (args) =>
ctx.runMutation(components.supportmate.tickets.updateStatus, args),
// Saved replies
listSavedReplies: () =>
ctx.runQuery(components.supportmate.savedReplies.listForBot, {}),
markSavedReplyUsed: ({ id }) =>
ctx.runMutation(components.supportmate.savedReplies.markUsed, { id }),
// Broadcasts
publishBroadcast: (args) =>
ctx.runMutation(components.supportmate.broadcasts.publish, {
...args,
kind: args.kind ?? "note",
}),
reactToBroadcast: (args) =>
ctx.runMutation(components.supportmate.broadcasts.reactToBroadcast, args),
},
});
await bot.handleUpdate(update);
},
});Step 5 — Wire push send in a separate "use node" action
sendPush takes a flat list of subscriptions and a VAPID config. Pull the
subscriptions from the component's internal query, fire the push, then delete
any stale endpoints the function returns.
// convex/pushActions.ts
"use node";
import { v } from "convex/values";
import { internalAction } from "./_generated/server";
import { components } from "./_generated/api";
import { sendPush } from "supportmate/server";
export const sendToUser = internalAction({
args: { userId: v.string(), title: v.string(), body: v.string() },
handler: async (ctx, args) => {
const subs = await ctx.runQuery(
components.supportmate.push.listForUser,
{ userId: args.userId },
);
const stale = await sendPush({
subscriptions: subs,
payload: { title: args.title, body: args.body },
vapid: {
publicKey: process.env.VAPID_PUBLIC_KEY!,
privateKey: process.env.VAPID_PRIVATE_KEY!,
subject: "mailto:[email protected]",
},
});
for (const endpoint of stale) {
await ctx.runMutation(
components.supportmate.push.deleteByEndpoint,
{ endpoint },
);
}
},
});Step 6 — Wire Discord cross-post
// convex/discordActions.ts
"use node";
import { v } from "convex/values";
import { internalAction } from "./_generated/server";
import { components } from "./_generated/api";
import { crossPostTicketToDiscord } from "supportmate/server";
export const announceNewTicket = internalAction({
args: { ticketId: v.string() },
handler: async (ctx, { ticketId }) => {
const ticket = await ctx.runQuery(
components.supportmate.roadmap.getTicketForCrossPost,
{ ticketId },
);
if (!ticket) return;
await crossPostTicketToDiscord({
webhookUrl: process.env.DISCORD_BROADCAST_WEBHOOK_URL!,
ticket,
event: "submitted",
publicAppUrl: process.env.SITE_URL,
});
},
});crossPostBroadcast works the same way for changelog announcements.
Step 7 — Mount the React widget
// app/layout.tsx (or your root layout)
import { SupportmateProvider, SupportFab } from "supportmate/react";
import { api, components } from "@/convex/_generated/api";
export default function Layout({ children }: { children: React.ReactNode }) {
return (
<SupportmateProvider api={api} components={components}>
{children}
<SupportFab app="your-app-key" />
</SupportmateProvider>
);
}Note: React components are coming in v0.1.0 — for v0.0.x, the component + server helpers are stable; build your own UI against the component API for now. Every read/write is a regular Convex query/mutation, so any Convex React hook (
useQuery,useMutation) over your wrapper functions works.
Step 8 — Add supportmate's classes to your Tailwind content scan
supportmate ships unbundled Tailwind class strings (size-11, bottom-4, fixed, etc.). Without scanning the published bundle, those classes don't get emitted into your stylesheet and the widgets render un-styled (e.g., the floating FAB collapses to 100% width).
Tailwind v4 (in your global CSS):
@import "tailwindcss";
@source "../node_modules/supportmate/dist/react.{js,cjs}";Adjust the relative path to point at node_modules/supportmate/dist/react.js from the file containing the directive.
Tailwind v3 (in tailwind.config.{js,ts}):
content: [
// ...your existing entries
"./node_modules/supportmate/dist/react.{js,cjs}",
],Step 9 — Copy the service worker
cp node_modules/supportmate/public/support-sw.js public/support-sw.jsThe service worker registers push handlers and forwards click events to your app. It's framework-agnostic.
Step 10 — Required env vars
Set these in your Convex deployment via npx convex env set <KEY> <VALUE>:
| Variable | Required | Notes |
| --- | :---: | --- |
| TELEGRAM_BOT_TOKEN | ✓ | Token from BotFather |
| TELEGRAM_CHAT_ID | ✓ | ID of the support forum supergroup |
| TELEGRAM_WEBHOOK_SECRET | ✓ | Generate: openssl rand -hex 32 |
| SUPPORTMATE_ADMIN_USER_ID | ✓ | Your Telegram user ID (admin / founder) |
| VAPID_PUBLIC_KEY | optional | Generate: npx web-push generate-vapid-keys |
| VAPID_PRIVATE_KEY | optional | Same command — keep paired with the public key |
| DISCORD_BROADCAST_WEBHOOK_URL | optional | Discord channel webhook for cross-post |
| SITE_URL | ✓ | Your app's base URL — used in admin links from Telegram |
After setting TELEGRAM_BOT_TOKEN and the webhook secret, register the webhook
with Telegram:
curl -X POST "https://api.telegram.org/bot$TELEGRAM_BOT_TOKEN/setWebhook" \
-d "url=https://<your-convex-deployment>.convex.site/telegram/webhook/support" \
-d "secret_token=$TELEGRAM_WEBHOOK_SECRET"Architecture
┌─────────────────┐
┌─────────────────────────► │ Telegram bot │ ◄──── admin
│ │ (grammy) │
│ └────────┬────────┘
│ │
│ ┌──────────────────────────────────┼─────────────────────┐
│ │ Your Convex deployment │ │
│ │ ┌──────────────────────────┐ │ webhook │
┌───┴──┴──┤ convex/support.ts │ ◄──┘ │
│ User │ (your auth + env wraps) │ │
│ widget │ └──────────────┬────────┘ │
│ (React) │ │ ctx.runMutation │
└───┬─────┘ ┌─────────────▼─────────────────┐ │
│ │ components.supportmate.* │ │
│ │ • threads.sendMessage │ │
│ │ • tickets.submitBugReport │ │
│ │ • roadmap.voteForTicket │ ──► Discord │
│ │ • broadcasts.publish │ ──► Web Push │
│ │ • push.subscribe │ ──► Email (opt)│
│ │ • panelReads.markRead │ │
│ └────────────────────────────────┘ │
└──── push ─────────────────────────────────────────────────┘Sub-exports
supportmate— core typessupportmate/convex.config— component install path (app.use(supportmate))supportmate/server—makeSupportBot,makeAnnounceBot,telegramWebhookHandler,sendPush,crossPostTicketToDiscord,crossPostBroadcastsupportmate/react— UI widgets (coming in v0.1.0)supportmate/public/support-sw.js— service worker file
Versioning + status
- v0.0.x — alpha. Expect breaking changes between patch versions.
- React widgets — pending (Phase 3). The component API and server helpers are stable; build your own UI against the wrapper functions for now.
- v0.1.0 — ships once the first pilot integration (pixbox) runs clean for a week.
License
MIT
