Tendkit

Search documentation

Find a docs page

Get Started

The service

createSupport: storage, channels, notifications, automatic replies and every option.

Create the service

createSupport returns the object your webhook route, scheduled job and API call. It holds no state of its own, so create it once per request (or once per worker) with your database client.

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

export const support = () =>
  createSupport({
    store: createPostgresStore(db),
    storage,
    channels: [
      resendChannel({
        id: "email",
        apiKey: process.env.RESEND_API_KEY!,
        webhookSecret: process.env.RESEND_WEBHOOK_SECRET!,
        address: "support@acme.com",
        replyAddress: "support@in.acme.com",
      }),
    ],
    directory,
    notify: async (n) => {
      if (n.type === "ticket.created") await emailTeam("New ticket", n.ticket.subject);
    },
    acknowledgement: () => ({
      text: "Thanks for writing to Acme. We've received your message and will reply to this email.",
    }),
    fromName: "Acme Support",
    portalChannelId: "email",
    ignoreFrom: ["noreply@acme.com"],
  });

Options

NameTypeDescription
store*SupportStorecreatePostgresStore(db) or createD1Store(db).
storage*AttachmentStorageWhere attachment bytes live. See below.
channels*Channel[]The adapters you use. Each needs a unique, permanent id.
directory*SupportDirectoryWho people are. See Your directory.
notify(n) => Promise<void>Alerts to your team. Errors are logged, never thrown into ingestion.
acknowledgement(ctx) => { text } | nullThe automatic first reply to new tickets. Return null to skip one.
signature(agent) => stringAppended to email replies only; the stored text stays what the agent wrote.Default: the agent's first name
fromNamestringThe display name replies are sent as, such as "Acme Support".
portalChannelIdstringThe email channel that portal requests and contact forms reply on.
ignoreFromstring[]Senders whose mail never becomes a ticket, such as your app's no-reply address.
reopenWindowDaysnumberA reply within this many days reopens a solved ticket; later replies open a new one that links back.Default: 14
maxAttachmentBytesnumberLarger inbound files are skipped; larger uploads are refused.Default: 25 MB
now() => DateFor tests.
log(level, message, data) => voidWhere warnings and errors go.Default: console

Attachment storage

Inbound attachments are copied into your storage as they arrive, and agents upload files to replies and notes. Download links are created on demand and expire after five minutes.

lib/support/storage.ts
import type { AttachmentStorage } from "@/lib/support/service";

// S3, R2, Supabase Storage, a disk: anything that can store bytes and hand out a download link.
export const storage: AttachmentStorage = {
  async put(key, body, contentType) {
    await bucket.put(key, body, { contentType });
  },
  async get(key) {
    return new Uint8Array(await bucket.get(key).arrayBuffer());
  },
  async url(key, { filename, expiresInSeconds }) {
    // A short-lived link that downloads (Content-Disposition: attachment).
    return bucket.signedUrl(key, { expiresIn: expiresInSeconds, downloadAs: filename });
  },
};

Serve files as downloads

Customer files are untrusted. Make url return links that download rather than display, ideally from a different origin than your app, so an HTML or SVG attachment can never run as a page on your domain.

Notifications

notify receives one of four events. Send them however your team works: email, Slack, push.

NameTypeDescription
ticket.created{ ticket, message, contact }A new ticket, including ones opened from the portal. Not sent for automatic mail or tickets marked as spam.
ticket.customer_replied{ ticket, message, contact }The customer wrote again. Usually goes to the assignee, or everyone when unassigned.
ticket.assigned{ ticket, assigneeId, actorId }Someone assigned the ticket.
message.failed{ ticket, message }A reply bounced or couldn't be delivered; message.deliveryError says why.

Automatic replies

acknowledgement runs for new tickets from senders your directory matched to a customer. Strangers, spam and spoofed senders never get one, so the inbox can't be used to send mail to arbitrary addresses. The reply carries Auto-Submitted: auto-replied so other systems don't answer it.

Methods

What the service returns. Expose the agent methods and the portal through your own API, behind your own auth.

NameTypeDescription
handleWebhook(channelId, request) => ResponseFor your webhook route. See Webhooks.
runScheduled() => Promise<summary>Polls channels, renews subscriptions, fails sends that never finished.
ingest(channelId, event)Feed an event directly, for tests or custom transports.
listTickets / getTicket / countsWhat the inbox and ticket screens read.
reply / retry / addNoteSend a reply (status and attachments included), retry a failed one, or add an internal note.
assign / setStatus / linkCustomerAssignment, status (open, waiting, solved, spam) and linking a sender to a customer.
uploadAttachment / attachmentUrlAgent uploads, and short-lived download links with access checks.
searchCustomers / listAgents / markReadPickers and read state per agent.
portal.list / get / create / replyThe customer portal. See Customer portal.

Errors

Methods throw SupportError with a code of not_found, forbidden, invalid, reply_window_closed or too_large, and a message written for the person using the screen. Map it to your API's error type and show the message as is.