Tendkit

Search documentation

Find a docs page

Get Started

Proactive and self-serve

Write to customers before they ask, tell everyone an incident affects on one approval, and let the portal answer from your docs and the customer's own account.

The best ticket is the one a customer never has to write. Your product already knows when a payout fails, a webhook keeps erroring or a document is about to expire. Tendkit turns that into a message your team sends first, and when an incident hits many customers at once, one approval tells each of them.

For the questions customers do have, the portal can answer at once: from your docs, your approved answers and the asking customer's own account. When it can't answer, it hands the conversation to your team as a ticket instead of guessing. Starting a conversation works without AI; everything else on this page needs AI and the 0007_proactive.sql migration.

Start a conversation

support.startTicket opens a ticket by writing to the customer, instead of waiting for them to write. It returns the ticket and the message.

Your API
// In your agent API. The signed-in agent is who writes.
const { ticket, message } = await support().startTicket({
  to: { address: "emma@northwind.test", name: "Emma Clarke" }, // or { contactId }
  cc: [{ address: "priya@northwind.test" }],
  subject: "About your payout schedule",
  text: "Hi Emma, we noticed your payout schedule changed.",
  agentId: agent.id,
});
  • The ticket opens as Waiting on customer, assigned to the agent, with a ticket_started event on the timeline. No ticket.created notification and no acknowledgement go out: your team wrote it.
  • The message takes the same path as a reply: the agent's signature on email, the Reply-To token, delivery tracking. The customer's answer threads into the ticket by its headers or by the token, and reopens it like any other reply.
  • to is someone the inbox already knows ({ contactId }) or an address. A new address is matched to an account through your directory; customerId files a person who isn't linked yet under that customer. A person who belongs to another customer is refused. A ticket linked to a customer shows in that customer's portal.
  • channelId defaults to portalChannelId when it can write first, then your first email channel, then the first channel that can. Channels with a subject need one. cc is for email, up to 20 addresses.
  • Attachments are uploaded first with uploadAttachment({ ticketId: null, … }) and passed as attachmentIds. Only the agent who uploaded a file can send it.
  • When the channel doesn't take the message, the ticket stays with the failed message, ready to retry.

Which channels can write first

A channel says so with the capability canStartConversation. support.listChannels() returns every channel with startBlockedReason (null, "needs_template" or "not_supported") and default: true on the one a new conversation uses.

ChannelWrites firstWhy not
Email (Resend, Microsoft 365, Gmail, SMTP)Yes
SMS (Twilio)Yes
WhatsApp (Twilio, Cloud API)Noneeds_template: outside the 24-hour window WhatsApp only accepts an approved template, which the adapters don't send.
TelegramNonot_supported: a bot can only write to people who wrote to it.
Signed APINonot_supported: it only receives.

support.listContacts({ customerId?, search?, limit? }) finds who to write to: a customer's people, or the people whose name or address matches a search, most recently updated first, each with their identities and customer. Blocked people are left out, and it returns nothing without a customer or a search.

With AI, support.ai.draftOpening({ intent, customerId?, contactId?, channelId? }) writes the first message from one line of intent, such as "tell them their payouts are failing". It uses the customer's account facts, your knowledge and your approved answers, writes in the customer's language when it is known, and returns { subject, text, sources } for the agent to read and send. It sends nothing, and throws with the reason when there isn't enough to write from.

app/api/support/route.ts
case "startTicket": {
  // Never take agentId from the request body.
  const { ticket } = await s.startTicket({ ...input, agentId: agent.id });
  return Response.json({ ticketId: ticket.id });
}
case "listChannels":
  return Response.json(await s.listChannels());
case "listContacts":
  return Response.json(await s.listContacts(input)); // { customerId?, search? }
case "uploadForNewTicket": {
  // file: the File from your multipart upload
  return Response.json(
    await s.uploadAttachment({
      ticketId: null, // a file for a conversation that doesn't exist yet
      filename: file.name,
      contentType: file.type,
      bytes: new Uint8Array(await file.arrayBuffer()),
      uploadedBy: agent.id,
    })
  );
}

// With AI:
case "ai.draftOpening":
  return Response.json(await s.ai.draftOpening(input)); // { intent, customerId?, contactId?, channelId? }

Signals and the Outreach queue

A signal is something your product noticed about one customer that they should hear before they ask. Report it with support.signal:

Your product
// Wherever your product notices it: a job, a webhook handler, a nightly check.
const result = await support().signal({
  customerId: "cus_northwind",
  kind: "payouts_failing",
  title: "Payouts have failed 3 times since Monday",
  facts: [
    { label: "Failed payouts", value: "3 since Monday" },
    { label: "Bank's reason", value: "Account closed", url: "https://acme.test/admin/payouts?customer=cus_northwind" },
  ],
  dedupeKey: "payouts_failing:cus_northwind:2026-11-09",
  // to: { address: "priya@northwind.test", name: "Priya Patel" },
});

// { status: "proposed", outreachId }
// { status: "duplicate", outreachId }
// { status: "suppressed", reason }
NameTypeDescription
customerId*stringThe customer it is about, as your directory knows them.
kind*stringWhat happened, as a stable name such as "payouts_failing": a lowercase letter, then lowercase letters, digits and underscores, up to 60 characters. Signals of one kind are deduplicated together and share a track record. "incident" is reserved.
title*stringOne line for your team, up to 200 characters.
facts{ label, value, url? }[]What you know, up to 20. With the account facts, they are the only details the message may state.
dedupeKeystringThe same key reported twice does nothing the second time.
to{ address, name?, locale? }Who to write to, when your product knows better than the defaults below.

Each signal that passes the rules becomes a proposal in the inbox's Outreach queue:

  • The right contact. The address in to, else the one your directory's outreachContact names for this kind, else the person at the customer who wrote to you last. It goes out on the channel a new conversation uses by default.
  • Their language. The contact's locale, else the locale you passed, else the language of the customer's recent tickets.
  • A draft with sources. Written from the signal's facts, the customer's account facts, your knowledge and your approved answers, and nothing else. With defer it is drafted right away, otherwise on the next runScheduled. When the facts don't hold enough to say something accurate, or drafting fails three times, the proposal still shows what happened and an agent writes the message.

An agent then sends it as drafted or edited, or dismisses it. Sending opens a ticket through startTicket, from that agent and assigned to them. Two agents sending the same proposal send it once. Dismissing keeps the customer from being proposed the same kind again for dedupeHours.

When a signal is left unsent

signal never throws for these: it answers with what happened, checked in this order.

ResultWhen
duplicateThe same dedupeKey was reported before, or a proposal of this kind already waits for this customer. Comes with that proposal's outreachId when there is one.
recently_contactedThe customer got a message of this kind within dedupeHours.
awaiting_replyThe last message of this kind went out within four times dedupeHours, its ticket is still Waiting and the customer hasn't written back.
dismissedAn agent dismissed a proposal of this kind for this customer within dedupeHours.
customer_limitThe customer already has maxPerCustomerPerDay proposals or messages from signals in the last 24 hours, across kinds. Dismissed ones don't count.
rate_limitedmaxPerHour signals became proposals in the last hour, across customers. A runaway job in your product stops here.
no_contactNobody at the customer has an address on a channel that can write first, or the address named belongs to another customer's person or to someone blocked.
unavailableAI isn't configured, proactive is false, or 0007_proactive.sql isn't applied.

Every result but duplicate comes back as { status: "suppressed", reason }. Suppressed signals are still recorded in support_signals with the reason.

Options

lib/support/index.ts
supportAi({
  model,
  store,
  knowledge: [docs],
  // Every value is optional. These are the defaults.
  proactive: {
    dedupeHours: 168,        // a customer hears about one kind once in 7 days
    maxPerCustomerPerDay: 2, // across kinds
    maxPerHour: 60,          // signals turned into proposals, across customers
    minDrafts: 10,           // decided proposals a kind needs before it can send on its own
    minRate: 0.9,            // share of them sent as drafted or lightly edited
    windowDays: 90,          // how far back a kind's track record looks
    holdMinutes: 10,         // how long a message that sends on its own waits first
    maxAutoPerHour: 20,      // messages nobody read first, across kinds
    // autoSend: false,                              // removes the per-kind switch
    // signature: ({ language }) => "Acme Support",  // what an unattended message signs with
  },
  selfServe: { maxPerHour: 20, maxQuestions: 8, quietHours: 24 },
});
NameTypeDescription
dedupeHoursnumberHours during which a customer isn't written to about the same kind again.Default: 168
maxPerCustomerPerDaynumberMessages one customer may get from signals in any 24 hours, across kinds.Default: 2
maxPerHournumberSignals turned into proposals in any hour, across customers. The rest are recorded and left unsent.Default: 60
minDraftsnumberDecided proposals a kind needs before it can be switched to send on its own.Default: 10
minRatenumberThe share of those that must have gone out as drafted or lightly edited, 0 to 1.Default: 0.9
windowDaysnumberDays of history a kind's track record looks at.Default: 90
holdMinutesnumberHow long a message of a kind that sends on its own waits in the queue first.Default: 10
maxAutoPerHournumberMessages that may go out with nobody reading them first in any hour.Default: 20
autoSendbooleanfalse removes the per-kind switch: every message waits for an agent.Default: true
signaturefalse | string | ({ language }) => stringWhat a message that went out on its own is signed with. false for none.Default: fromName and a notice

proactive: false switches signals off: nothing is recorded and every signal answers unavailable.

Sending on its own

Nothing sends on its own by default. A kind of outreach earns it the way a topic earns Autopilot, and a person switches it on.

  • The track record. For each kind, over windowDays: how many proposals were sent as drafted, lightly edited, rewritten or dismissed, measured the same way as Autopilot's. Two things that happen after sending count against the kind too: the customer's first answer to the message is unhappy (they sound frustrated or angry, or say it was wrong), or an agent flags the message. Proposals AI couldn't draft and incident messages aren't counted.
  • Eligible. A kind is ready when at least minRate of its latest minDrafts decided proposals went out as drafted or lightly edited: by default 90% of 10. support.ai.setOutreachKind({ kind, on: true, actorId }) is refused until then, and records who switched it on and when. Switching a kind on or off goes through Autopilot's canManage when you set one.
  • Held first. Once a kind is on, each new message of it waits in the queue for holdMinutes, shown with the time it goes. Any agent can stop it (holdOutreach), send it now or dismiss it. runScheduled sends the ones that are due.
  • What a message must pass. The model marked it confident, every source it cited exists and at least one isn't the guidance, and fewer than maxAutoPerHour went out in the last hour. Otherwise it waits for an agent, with the reason in autoBlock: stopped, not_confident, unsourced, hourly_cap or held_back.
  • Never as a person. A message that went out on its own has no author, its ticket is unassigned, and it is signed with your fromName and "This message was sent automatically. Reply to it and our team will pick it up." listOutreach({ sent: true }) returns it with auto: true.
  • The kill switch. Autopilot's Stop (setAutopilot({ stopped: true })) stops outreach too. It is checked when a message is drafted and again just before it is sent.
  • Automatic pause. When a kind's latest decided proposals fall under minRate (judged from 10 proposals on, or from minDrafts when that is lower), the kind pauses. This is checked on every runScheduled and each time a message of the kind is drafted. It can be switched on again once the record has recovered. A held message is sent only while its kind is still on: one whose kind was paused or switched off in the meantime waits for an agent.
  • Flag as wrong. On its ticket, a message that went out on its own carries a one-click flag for agents (support.ai.flagOutreach({ id, actorId, note? }), where id is the outreach id). Any agent can raise it. The kind pauses at once, held messages of the kind return to the queue, the ticket goes back to open and gets an outreach_flagged event. The kind stays paused until someone switches it on or off again, which counts as the review of the flag. Messages an agent sent can't be flagged.
  • Switching off. setOutreachKind({ kind, on: false, actorId }) also returns every held message of the kind to the queue for an agent.

support.ai.getOutreachKinds() returns the readiness view: every kind that has signalled within windowDays or was ever switched on, each with its stage (collecting, eligible, on, paused), its record, the title of its latest signal, who switched it on and, while paused, why (pauseReason: quality or flagged), plus the thresholds, holdMinutes, whether the switch exists (autoSend) and whether the kill switch is on (stopped).

app/api/support/route.ts
case "ai.listOutreach":
  return Response.json(await s.ai.listOutreach(input)); // { sent?: boolean }
case "ai.sendOutreach":
  // The signed-in agent is the approval. Never take actorId from the request body.
  return Response.json(await s.ai.sendOutreach({ ...input, actorId: agent.id }));
case "ai.dismissOutreach":
  return Response.json(await s.ai.dismissOutreach({ ...input, actorId: agent.id }));
case "ai.holdOutreach":
  return Response.json(await s.ai.holdOutreach({ ...input, actorId: agent.id }));
case "ai.flagOutreach":
  return Response.json(await s.ai.flagOutreach({ ...input, actorId: agent.id })); // { id, note? }
case "ai.getOutreachKinds":
  return Response.json(await s.ai.getOutreachKinds());
case "ai.setOutreachKind":
  return Response.json(await s.ai.setOutreachKind({ ...input, actorId: agent.id }));

Incident broadcast

When Insights detects a spike, the customers who wrote in are the ones who need to hear from you. After an agent acknowledges the spike, "Message customers" writes to all of them on one approval. Each method refuses a spike nobody has acknowledged.

  • previewBroadcast({ spikeId }) returns who it would go to: one person per customer, the one who wrote in about the spike's topic while it lasted, on their latest ticket. It also counts who is left out: senders not linked to a customer (unlinked), customers already written to about this spike (alreadyMessaged) and customers with nobody reachable (unreachable). Nothing is sent.
  • draftBroadcast({ spikeId, intent }) writes the message from one line of intent, with {name} and {company} where each customer's go, in the language the intent was written in. It states nothing about any one account.
  • sendBroadcast({ spikeId, subject, text, actorId }) opens one ticket per customer, from the agent who approved it and assigned to them. {name} becomes the person's first name and {company} the customer's name; a missing name is written around, so "Hi {name}," becomes "Hi,".
  • Approving twice sends once. A customer gets one message per spike, enforced by a unique index, whoever approves and however often.
  • The first 20 messages go out while the agent waits. The rest are sent by the next runScheduled runs, 25 per run. The result says how many were sent, queued and failed.
  • Incident messages are not in the Outreach queue, don't count toward a kind's track record or a customer's daily limit, and each spike shows how many customers were written to (messaged).
Your API
const s = support();

// Who it would go to. Nothing is sent.
const preview = await s.ai.previewBroadcast({ spikeId });
// { topic, channel, recipients: [{ customer, contact, ticket }], skipped: { unlinked, alreadyMessaged, unreachable } }

// Optional: a draft from one line.
const draft = await s.ai.draftBroadcast({ spikeId, intent: "Card payments are slow to confirm. Nothing is lost. We'll write again when it's fixed." });

// One approval. Each customer gets their own ticket.
const result = await s.ai.sendBroadcast({
  spikeId,
  subject: "Card payments are delayed",
  text: "Hi {name},

Card payments for {company} have been slow to confirm since 09:40 UTC. No payment is lost. We are working on it and will write again when it is fixed.",
  actorId: agent.id,
});
// { recipients: 34, sent: 20, queued: 14, failed: 0 }
app/api/support/route.ts
case "ai.previewBroadcast":
  return Response.json(await s.ai.previewBroadcast(input)); // { spikeId }
case "ai.draftBroadcast":
  return Response.json(await s.ai.draftBroadcast(input)); // { spikeId, intent }
case "ai.sendBroadcast":
  // The signed-in agent is the approval. Never take actorId from the request body.
  return Response.json(await s.ai.sendBroadcast({ ...input, actorId: agent.id }));

Self-serve in the portal

The portal's Ask box answers a signed-in customer's question at once, with the sources it used. Wire three methods on your server. Each takes customerId and userId from your session: they decide whose account is read.

Your portal API
// Your portal API. Who is asking comes from your session, never from the request.
const s = support();
const who = { customerId: user.accountId, userId: user.id };

switch (action) {
  case "list":
    // Now also returns ask: true when your team switched instant answers on.
    return Response.json(await s.portal.list({ ...who, readerId: user.id }));
  case "ask":
    return Response.json(await s.portal.ask({ ...who, question: input.question, sessionId: input.sessionId }));
  case "rateAnswer":
    return Response.json(await s.portal.rateAnswer({ ...who, sessionId: input.sessionId, helpful: input.helpful }));
  case "escalate":
    return Response.json(
      await s.portal.escalate({ ...who, email: user.email, name: user.name, locale: user.locale, sessionId: input.sessionId, text: input.text })
    );
}
app/support/client.ts
export const portalClient: SupportPortalClient = {
  // …list, get, create, reply, attachmentUrl
  ask: (input) => call("ask", input),
  rateAnswer: (input) => call("rateAnswer", input),
  escalate: (input) => call("escalate", input),
};
What ask returns
type AskResult =
  | { sessionId: string; kind: "answer"; text: string; sources: SuggestionSource[] }
  | { sessionId: string | null; kind: "handoff"; reason: "cannot_answer" | "asked_for_human" | "blocked" | "limit" };

Pass sessionId back to continue a conversation: earlier questions and answers are part of the next one. rateAnswer records the customer's thumbs-up or thumbs-down.

Off until your team switches it on

The Ask box answers nothing until someone on your team switches it on in SupportAiSettings (support.ai.updateSettings({ selfServe: true })). portal.list() returns ask: true once it is on, which is what shows the box. Until then portal.ask throws. supportAi({ selfServe: false }) removes it altogether, the switch included.

What it answers from

  • Your knowledge articles and your approved answers.
  • The asking customer's own account facts, from your directory's describeCustomer.
  • Lookups you marked portal: true: read-only actions (approval: "never"), run for the signed-in customer, up to six per answer. A lookup the team switched off in AI settings isn't run. Actions that change something are never available here.
lib/support/index.ts
supportAction({
  name: "last_invoice",
  title: "Look up last invoice",
  description: "The customer's most recent invoice and whether it's paid.",
  input: z.object({}),
  approval: "never",
  portal: true, // the Ask box may run it, for the signed-in customer only
  // In the portal there is no ticket yet: ticket and contact are null.
  run: async ({ customer }) => {
    const invoice = await billing.lastInvoice(customer.id);
    if (!invoice) return { ok: true, summary: "No invoices yet." };
    return { ok: true, summary: `Invoice ${invoice.number} for ${invoice.total}, ${invoice.status}` };
  },
}),

Your team's guidance shapes the tone, and never counts as a source. An answer is shown only when the model marked it confident and every source it cited exists. It is written in the language of the question.

Handing off

When it can't answer, ask returns kind: "handoff" with a reason, and the Ask box offers to send the question to your team:

NameTypeDescription
cannot_answerThe knowledge, the approved answers and the account don't hold the answer, the model wasn't confident, or answering failed.
asked_for_humanThe customer asked for a person, a call or an escalation, or is plainly upset.
blockedThe question read like an attempt to steer the assistant, or the answer recited your guidance.
limitThe customer reached the hourly limit, or the conversation its length.

portal.escalate turns the conversation into a request on portalChannelId, the same as portal.create: the first question is the subject, and the latest question (or text, when the customer reworded it) is the message. The ticket carries an internal note for the agent with every question, what AI answered, what it drafted but didn't show, and the sources. The customer never sees the note. A customer can also escalate after an answer. A conversation is handed off once; a later question starts a new one.

What keeps it safe

One account. Account facts and lookups are read for the customerId your server passed, by code the question can't reach. A lookup gets that customer and no ticket, and a sessionId only continues a conversation that belongs to the same customer and user. Injection. A question that reads like an attempt to steer the assistant or to reach another account is handed off before any model call. Everything in the question is treated as data. An answer that repeats ten words in a row from your guidance is not shown. Caps. One customer may ask 20 questions an hour, and a conversation holds 8 before it goes to a person (maxPerHour, maxQuestions). No guessing. An answer the model isn't sure of is never shown: it goes to the agent in the hand-off note instead.

Deflection in Insights

support.ai.insights() gains selfServe: { current, previous }, and Insights shows it as questions answered before a ticket. Each side counts the conversations started in its period:

NameTypeDescription
conversationsnumberConversations customers started in the Ask box.
answerednumberThose that got at least one answer.
deflectednumberAnswered, not handed off, and either the customer gave a thumbs-up, or they gave no rating, asked nothing more for quietHours (24) and that person opened no ticket since the conversation began.
escalatednumberHanded to your team as a ticket.
helpful / notHelpfulnumberConversations whose latest rating was a thumbs-up or a thumbs-down.

A thumbs-down is never counted as deflected, and neither is a conversation still inside its quiet period. So the number for the last day grows as conversations age.

Store and migration

Apply 0007_proactive.sql and then 0008_outreach_record.sql, which ship with support-ai-postgres and support-ai-d1, before you deploy. The second adds the columns behind a kind's record after sending: follow_up and the flag columns on support_outreach, and pause_reason on support_outreach_kinds. The first adds:

NameTypeDescription
support_signalstableEvery signal with what became of it: proposed, duplicate or suppressed with the reason. A unique index on dedupe_key.
support_outreachtableProposals and messages, from signals and from broadcasts: the draft, what was sent, its sources, status, who decided and the ticket. A unique index on spike and customer.
support_outreach_kindstableEach kind's switch for sending on its own, and who set it.
support_ask_sessionstableAsk conversations: the questions, what came of each, the rating and the ticket it was handed off to.
support_ai_settings.self_servecolumnThe team's switch for the Ask box. False by default.

Starting a conversation needs no migration. proactive.ts is a new file in the core support item: it holds the parts that need no model and no database, including PROACTIVE_DEFAULTS, SELF_SERVE_DEFAULTS and the rules above. If you wrote your own store, see Upgrading for the new methods.

Screens and client methods

Every method is optional, and each screen appears when your client has the methods it needs. Pass the signed-in agent's id as agentId or actorId on your server.

NameTypeDescription
NewTicketDialog, NewTicketButtonnew-ticket-dialog.tsxNew ticket in the inbox and Message on a ticket's customer card. Needs client.startTicket. listChannels lets the agent choose the channel and explains the ones that can't write first, listContacts finds people, uploadForNewTicket adds attachments, and ai.draftOpening adds a draft from one line.
OutreachQueueoutreach.tsxThe inbox's Outreach tab. Needs ai.listOutreach; ai.sendOutreach, ai.dismissOutreach and ai.holdOutreach are its buttons.
OutreachMarkautopilot.tsxOn a ticket, under a message that went out on its own: "Sent automatically from a signal" and the flag. The flag needs ai.flagOutreach.
OutreachKindsoutreach.tsxEach kind's readiness and switch, inside SupportAiSettings. Needs ai.getOutreachKinds and ai.setOutreachKind.
BroadcastDialogoutreach.tsxOpened from a spike's "Message customers". Needs ai.previewBroadcast and ai.sendBroadcast; ai.draftBroadcast adds the draft from one line.
SupportAsksupport-ask.tsxThe Ask box at the top of SupportRequests. Needs ask on the portal client and ask: true from list(); rateAnswer and escalate are its thumbs and its hand-off.
app/admin/support/client.ts
export const supportClient: SupportClient = {
  // …
  startTicket: (input) => call("startTicket", input),
  listChannels: () => call("listChannels"),
  listContacts: (input) => call("listContacts", input),
  uploadForNewTicket: ({ file }) => uploadFile(null, file),
  ai: {
    // …
    draftOpening: (input) => call("ai.draftOpening", input),
    listOutreach: (input) => call("ai.listOutreach", input),
    sendOutreach: (input) => call("ai.sendOutreach", input),
    dismissOutreach: (input) => call("ai.dismissOutreach", input),
    holdOutreach: (input) => call("ai.holdOutreach", input),
    flagOutreach: (input) => call("ai.flagOutreach", input),
    getOutreachKinds: () => call("ai.getOutreachKinds"),
    setOutreachKind: (input) => call("ai.setOutreachKind", input),
    previewBroadcast: (input) => call("ai.previewBroadcast", input),
    draftBroadcast: (input) => call("ai.draftBroadcast", input),
    sendBroadcast: (input) => call("ai.sendBroadcast", input),
  },
};

For the self-serve switch in SupportAiSettings, return selfServe from client.ai.getSettings() (support.ai.getSettings() already does) and pass it through updateSettings. For deflection, client.ai.insights() already carries selfServe.

What leaves your servers

A signal's facts, an agent's intent and a customer's question, with that customer's account facts and what a portal lookup returns, go to the model provider you configured, like the rest of AI. Questions and answers are stored in support_ask_sessions in your database.