Tendkit

Add a support inbox to your app

Tendkit targets Next.js (App Router), Tailwind 4, shadcn/ui, TanStack Query and Drizzle. Other stacks work by editing the copied code, which is yours.

1. Install

Add the core, a store for your database, the screens, and one adapter per channel. Use support-d1 instead of support-postgres for SQLite or Cloudflare D1.

Terminal
npx shadcn@latest add \
  https://tendkit.softwaresavants.dev/r/support.json \
  https://tendkit.softwaresavants.dev/r/support-postgres.json \
  https://tendkit.softwaresavants.dev/r/support-ui.json \
  https://tendkit.softwaresavants.dev/r/channel-resend.json

In a monorepo, run it with --cwd packages/api for the server items and --cwd apps/web for support-ui, then fix the few imports that cross packages.

2. Add the tables

Export the support schema from your Drizzle schema and generate a migration. The copied 0000_init.sql is the reference. For D1 there are no transactions: the store removes what it created if a later write fails.

db/schema/index.ts
export * from "@/lib/support/postgres/schema";

3. Create the service

Build it once per request with your database, file storage, channels and directory.

lib/support/index.ts
import { createSupport } from "@/lib/support/service";
import { createPostgresStore } from "@/lib/support/postgres/store";
import { resendChannel } from "@/lib/support/channels/resend";

export const support = () =>
  createSupport({
    store: createPostgresStore(db),
    storage, // put / get / signed url: S3, R2, Supabase Storage…
    channels: [
      resendChannel({
        id: "email", // stored on every ticket: never change it
        apiKey: process.env.RESEND_API_KEY!, // Full access: reading received mail needs it
        webhookSecret: process.env.RESEND_WEBHOOK_SECRET!,
        address: "support@acme.com",
        replyAddress: "support@in.acme.com",
      }),
    ],
    directory, // step 4
    notify: async (n) => { /* email your team: n.type, n.ticket */ },
    acknowledgement: () => ({ text: "Thanks, we've received your message and will reply to this email." }),
    fromName: "Acme Support",
    portalChannelId: "email",
    ignoreFrom: ["noreply@acme.com"], // your app's own alerts to support@
  });

4. Tell it who people are

The directory is the only part that knows your product. identify matches a sender to a user and account; the rest power search, linking and assignment.

lib/support/directory.ts
export const directory: SupportDirectory = {
  async identify({ kind, value }) {
    if (kind !== "email") return null;
    const user = await db.query.users.findFirst({ where: eq(users.email, value) });
    return user ? { customerId: user.accountId, userId: user.id, name: user.name } : null;
  },
  searchCustomers: (q) => db.select({ id: accounts.id, name: accounts.name }).from(accounts).where(ilike(accounts.name, `%${q}%`)).limit(10),
  getCustomers: (ids) => db.select({ id: accounts.id, name: accounts.name }).from(accounts).where(inArray(accounts.id, ids)),
  listAgents: () => db.select({ id: users.id, name: users.name }).from(users).where(eq(users.role, "admin")),
};

5. Receive webhooks and run the schedule

Route provider webhooks to handleWebhook. It verifies the signature, ingests the events and answers the provider.

app/api/webhooks/support/[channel]/route.ts
export async function POST(request: Request, { params }: { params: Promise<{ channel: string }> }) {
  const { channel } = await params;
  return support().handleWebhook(channel, request);
}

Call support().runScheduled() every few minutes from a cron. It polls mailboxes and renews subscriptions for the channels that need it, and does nothing for push-only channels.

6. Mount the screens

Expose the service through your own API layer, behind your own auth, then implement SupportClient with it and wrap the screens in SupportProvider. Pass customerPanel to show your own data next to each ticket.

app/admin/support/page.tsx
<SupportProvider
  client={supportClient}
  viewerId={user.id}
  inboxHref="/admin/support"
  ticketHref={(id) => `/admin/support/${id}`}
  customerPanel={(detail) => <AccountPanel accountId={detail.customer?.id} />}
>
  <SupportInbox />
</SupportProvider>

Customers get SupportRequests and SupportRequest inside SupportPortalProvider, backed by support().portal. You decide who sees what: pass a userId to show someone only the requests they opened.

Taking upstream fixes

Copied code doesn't update itself. Run npx shadcn@latest add <item> --diff to see what changed and apply what you want. Keep your own changes in the directory, notifier and UI wrappers so the engine files stay easy to diff.