Tendkit

Search documentation

Find a docs page

Get Started

AI

Optional: triage, suggested replies from your docs, a memory of what your team teaches it, and Autopilot for the topics that have earned it, with any AI SDK model.

AI is optional. Without it, nothing in Tendkit changes. With it, every customer message is read as it arrives: the inbox shows what the customer needs and how urgent it is, and the ticket opens with a reply already written from your docs. An agent reviews, changes and sends it. Nothing is sent on its own, except on the topics your team switches to Autopilot once their track record has earned it.

AI also works before a ticket exists: it drafts the message when your product reports a problem on a customer's account, writes an incident message for every customer a spike affects, and answers questions in the portal. Those are on their own page, Proactive and self-serve.

Set it up

  1. Add the item for your database

    Terminal
    npx shadcn@latest add @tendkit/support-ai-postgres   # or @tendkit/support-ai-d1
    npm install @ai-sdk/anthropic                        # the provider you use

    It brings support-ai (the engine, which depends on the AI SDK's ai package and nothing else) and its tables. If you installed Tendkit before AI existed, update support, your store item and support-ui first: see Upgrading.

  2. Create the tables

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

    Then generate and apply a migration with Drizzle as usual. lib/support/postgres/0001_ai.sql, 0002_memory.sql, 0003_actions.sql, 0004_autopilot.sql, 0006_insights_ai.sql, 0007_proactive.sql and 0008_outreach_record.sql are the reference.

  3. Give it a model and your docs

    lib/support/index.ts
    import { anthropic } from "@ai-sdk/anthropic"; // or any other AI SDK provider
    import { supportAi } from "@/lib/support/ai";
    import { docsFromUrl } from "@/lib/support/knowledge";
    import { createPostgresAiStore } from "@/lib/support/postgres/ai-store";
    
    export const support = () =>
      createSupport({
        // …your existing options
        ai: supportAi({
          model: anthropic("claude-sonnet-4-5"),
          store: createPostgresAiStore(db),
          knowledge: [docsFromUrl("https://acme.com/llms-full.txt")],
        }),
      });
  4. Turn the screens on

    Add ai to your SupportClient. See Screens below.

The first knowledge sync happens on the next runScheduled. Call support().ai.syncKnowledge() once to have it right away.

What it does

  • Understands each message. For every new customer message (never automatic mail, your team's mail or spam), the fast model reports a topic from your list, an urgency with its reason, the customer's mood and language, and a one-line summary. The inbox lists open tickets by urgency, then by wait, and shows the summary. Agents can correct the topic and urgency; their choice is kept and recorded on the ticket.
  • Suggests the reply. The main model then writes a reply in the customer's language from your team guidance, your knowledge, the customer's account facts and the conversation, within the channel's length limit and without a signature (yours is added on sending). When those don't hold the answer, it says so in one line instead of guessing. Each suggestion lists what it's based on.
  • Helps with the wording. In the reply box: Shorter, Friendlier, More formal, Translate, any instruction you type or say, and Undo. "Write it up" turns an agent's rough notes into the reply. Customers' messages in another language can be translated in place.
  • Remembers what happened. Each suggestion is stored with its model, token counts and outcome: used as written, edited (with the text that was sent), discarded, or stale because the customer wrote again.

Customer text is data

Messages go into the prompt as quoted data with their markup escaped, and the model is told never to follow instructions inside them. Internal notes are passed as context that must never be quoted. Source titles and links shown to agents come from your stored articles and your directory, never from the model's output.

Options

NameTypeDescription
model*LanguageModelWrites suggested replies and write-ups.
store*AiStorecreatePostgresAiStore(db) or createD1AiStore(db), on the same database as your support store.
fastModelLanguageModelTriage, rewrites and translations. A small, fast model is enough.Default: model
knowledgeKnowledgeSource[]What suggestions may answer from. See Knowledge.Default: []
actionsSupportAction[]What AI may do in your product for the customer on the ticket. See Actions.Default: []
autopilotfalse | AutopilotOptionsThresholds, checks and limits for the topics your team lets answer on their own. Nothing is on until someone switches a topic on. false removes it. See Autopilot.
spikesfalse | { minCustomers?, windowMinutes?, factor?, baselineDays? }When a topic counts as a likely incident: by default 5 different customers within 120 minutes, at 3 times its usual volume over the last 14 days. false switches detection off. See Spikes.
proactivefalse | ProactiveOptionsLimits and thresholds for outreach from support.signal(). false switches signals off. See Proactive and self-serve.
selfServefalse | { maxPerHour?, maxQuestions?, quietHours? }Caps for the portal's Ask box (20 questions a customer an hour, 8 a conversation), which answers nothing until your team switches it on in AI settings. false removes it. See Self-serve.
defer(work: Promise) => voidRuns the model calls after the webhook has answered. Without it they run in the next runScheduled.
agentLanguagestringThe language your agents read (BCP-47), for summaries, reasons and translations.Default: "en"
knowledgeBudgetnumberCharacters of knowledge a prompt may carry. Past it, only the articles closest to the conversation are sent.Default: 120000
cacheproviderOptionsSet on the shared prompt prefix, for providers that only cache a prefix when it's marked.
transcriptionModelTranscriptionModelSpeech to text for voice in the reply box. Without it, voice uses the browser's own speech recognition. See Voice.
transcriptionOptions({ language? }) => providerOptionsProvider options for each transcription, such as a language hint.
shareStyleNotesbooleanLets what agents changed in one customer's draft shape other customers' drafts, as a style note without customer details. Set false to keep every correction within its customer. See Memory.Default: true
embedder{ embed(texts) => number[][] }Orders the earlier corrections found for a draft by meaning instead of shared words. Optional; nothing is stored.

Providers

Tendkit imports only the ai package, never a provider. model is any AI SDK language model, so you choose the provider, the model and where the key lives:

import { openai } from "@ai-sdk/openai";
import { google } from "@ai-sdk/google";

supportAi({ model: openai("gpt-5"), fastModel: openai("gpt-5-mini"), store });
supportAi({ model: google("gemini-2.5-pro"), fastModel: google("gemini-2.5-flash"), store });
// Through the Vercel AI Gateway, a model is just a string:
supportAi({ model: "anthropic/claude-sonnet-4-5", store });

Guidance and knowledge come first in every drafting prompt, in a stable order, so providers that cache prompt prefixes reuse them from one ticket to the next. For a provider that needs the prefix marked, pass the marker as cache, for example { anthropic: { cacheControl: { type: "ephemeral" } } }.

Knowledge

A knowledge source has an id and a load() that returns articles: { id, title, url?, text }. Two are included, and writing your own takes a few lines.

lib/support/knowledge-sources.ts
import { readFileSync, readdirSync } from "node:fs";
import { docsFromFiles, docsFromUrl, type KnowledgeSource } from "@/lib/support/knowledge";

// Markdown or MDX you already have. Frontmatter titles are read; imports and JSX tags are left out.
const dir = "content/docs";
const files = Object.fromEntries(
  (readdirSync(dir, { recursive: true }) as string[])
    .filter((path) => /\.mdx?$/.test(path))
    .map((path) => [path, readFileSync(`${dir}/${path}`, "utf8")])
);
const docs = docsFromFiles(files, { url: (path) => `https://acme.com/docs/${path.replace(/\.mdx?$/, "")}` });

// One file with everything, split on its top-level "# " headings.
const helpCenter = docsFromUrl("https://acme.com/llms-full.txt", { id: "help-center" });

// Or anything else: a CMS, a database table, saved replies.
const policies: KnowledgeSource = {
  id: "policies",
  load: async () => (await cms.list("policy")).map((p) => ({ id: p.slug, title: p.title, url: p.url, text: p.body })),
};

Articles are copied into support_knowledge once a day by runScheduled, and whenever you call ai.syncKnowledge() or press Sync now. Only articles whose content changed are rewritten; articles that disappeared from a source are removed, and so is a source you take out of the config. If a source fails to load, its stored articles keep answering until the next sync.

No file system at runtime?

On Cloudflare Workers and similar runtimes, use docsFromUrl with a file you publish, or import your Markdown as strings at build time and pass them to docsFromFiles.

Account facts

Add describeCustomer to your directory to return a few lines about a customer's account. They appear in the ticket's sidebar, with or without AI. With AI, they go into the prompt for that customer's own tickets only, and a suggestion can cite them.

lib/support/directory.ts
export const directory: SupportDirectory = {
  // …identify, searchCustomers, getCustomers, listAgents

  async describeCustomer(customerId) {
    const account = await db.query.accounts.findFirst({ where: eq(accounts.id, customerId) });
    if (!account) return [];
    return [
      { label: "Plan", value: account.plan, url: `https://admin.acme.com/accounts/${account.id}` },
      { label: "Balance", value: formatMoney(account.balance) },
      { label: "Last payout", value: account.lastPayout ? formatPayout(account.lastPayout) : "None yet" },
    ];
  },
};

It's only called for tickets linked to a customer, so unknown and spoofed senders never see account data in a reply.

When it runs

Receiving a message only queues the work, so a slow or failing model never delays or breaks a webhook. Pass defer to start right after the response is sent:

// Cloudflare Workers
supportAi({ model, store, defer: (work) => ctx.waitUntil(work) });

// Next.js
import { after } from "next/server";
supportAi({ model, store, defer: (work) => after(() => work) });

Without defer, the next runScheduled picks it up, so suggestions arrive within your cron interval. Either way, a failed attempt is logged and retried by runScheduled, three times at most. The model is never called inside a database transaction or while a connection is held: Tendkit reads, calls the model, then writes.

Screens

The AI parts are in support-ui and appear when your client has ai. Expose the methods through your API, then add them to the client:

app/api/support/route.ts
case "listTickets":
  return Response.json(
    await s.listTickets(
      {
        status: input.status,
        assigneeId: input.assignee === "me" ? agent.id : input.assignee,
        search: input.search,
        // The inbox asks for "priority" on open tickets when the client has `ai`.
        sort: input.sort ?? (input.status === "open" ? "waiting" : "recent"),
        limit: input.pageSize,
        offset: (input.page - 1) * input.pageSize,
      },
      agent.id
    )
  );
case "ai.refine":
  return Response.json(await s.ai.refine(input));
case "ai.writeUp":
  return Response.json(await s.ai.writeUp(input));
case "ai.translate":
  return Response.json(await s.ai.translate(input));
case "ai.draft":
  return Response.json(await s.ai.draft(input));
case "ai.discardSuggestion":
  return Response.json(await s.ai.discardSuggestion(input.id));
case "ai.setInsight":
  return Response.json(await s.ai.setInsight({ ...input, actorId: agent.id }));
case "ai.getSettings":
  return Response.json({ ...(await s.ai.getSettings()), sources: await s.ai.listKnowledgeSources() });
case "ai.updateSettings":
  return Response.json(await s.ai.updateSettings(input));
case "ai.syncKnowledge":
  return Response.json(await s.ai.syncKnowledge());
app/admin/support/client.ts
export const supportClient: SupportClient = {
  // …the methods you already have
  ai: {
    refine: (input) => call("ai.refine", input),
    writeUp: (input) => call("ai.writeUp", input),
    translate: (input) => call("ai.translate", input),
    draft: (input) => call("ai.draft", input),
    discardSuggestion: (input) => call("ai.discardSuggestion", input),
    setInsight: (input) => call("ai.setInsight", input),
    getSettings: () => call("ai.getSettings"),
    updateSettings: (input) => call("ai.updateSettings", input),
    syncKnowledge: () => call("ai.syncKnowledge"),
  },
};

// app/admin/support/ai/page.tsx
import { SupportAiSettings } from "@/components/support/support-ai-settings";

export default function Page() {
  return <SupportAiSettings />;
}

getTicket and listTickets already return what the screens need (insight, suggestion, accountFacts, urgency, summary, draftReady), and the reply box sends suggestionId with reply. SupportAiSettings is the screen where your team writes its guidance, lists its topics, turns suggestions on arrival on or off and syncs knowledge. Try all of it in the demo, where the AI is scripted.

Voice

The reply box has one microphone button, so an agent can speak instead of type, in any language. What happens with their words follows from what's in the box:

  • An empty reply box takes notes. "Tell her verification is pending, offer the onboarding call" goes through Write it up and comes back as a finished reply in the customer's language and your tone.
  • A reply box with text takes a change. "Shorter, and mention the call" is applied to the reply like a typed instruction, and Undo brings the earlier text back.
  • Insert as spoken puts the words in as text at the cursor, with no rewrite. The choice sits next to the transcript while the agent speaks and is remembered in that browser. Internal notes always work this way.

While listening, the agent sees the input level, the time against the two-minute limit, and the transcript, which stays in view while it's applied. Done (or the button again) applies it, Escape cancels at any point, and ⌘ Shift Space (Ctrl Shift Space on Windows and Linux) starts and stops without the mouse. At two minutes the recording stops and is applied. If the model call fails, the transcript is kept so the agent can try again without repeating themselves.

Where speech becomes text depends on your setup:

  • With a transcription model. Set transcriptionModel to any AI SDK transcription model (OpenAI, ElevenLabs, Deepgram, Groq and others) and add transcribe to your client. The browser records, your server passes the audio to the provider, and the provider detects the language.
  • Without one. The reply box uses the browser's own speech recognition (the Web Speech API, in Chrome, Edge and Safari), listening in the speechLocale you give SupportProvider, or its locale when you give none. Speech recognition wants a region, so pass "fr-FR" when your UI locale is a bare "fr". This is what you get with a provider that has no speech to text, such as Anthropic.
  • With neither (Firefox without a transcription model, for example), there is no microphone button.
lib/support/index.ts
import { openai } from "@ai-sdk/openai"; // or elevenlabs, deepgram, groq…

supportAi({
  model,
  store,
  transcriptionModel: openai.transcription("gpt-4o-transcribe"),
  // Optional. The AI SDK has no shared language setting, so a hint goes to your provider here.
  transcriptionOptions: ({ language }) => ({ openai: { ...(language ? { language } : {}) } }),
});
Sending the recording
// app/api/support/transcribe/route.ts
export async function POST(request: Request) {
  await requireAgent(request);
  const audio = (await request.formData()).get("audio") as File;
  return Response.json(await support().ai.transcribe({ audio }));
}

// app/admin/support/client.ts
ai: {
  // …the methods above
  async transcribe({ audio }) {
    const body = new FormData();
    body.set("audio", audio);
    const response = await fetch("/api/support/transcribe", { method: "POST", body });
    if (!response.ok) throw new Error("Transcription failed");
    return response.json();
  },
},

Use a transcription model for languages other than English

Browser speech recognition listens in one language at a time and is noticeably weaker outside English. For teams that speak Arabic, French or a mix of languages, a transcription model is far more accurate and detects the language on its own.

Audio is never stored: the recording lives in the browser's memory until it's sent, and ai.transcribe hands it to the provider and returns the text. Recordings are about half a megabyte for two minutes; ai.transcribe refuses anything over 10 MB. With browser speech recognition, the browser decides where audio is processed, and Chrome sends it to Google. If the microphone is blocked, missing or hears nothing, the reply box says so and how to fix it.

Memory

Every day your team answers questions your docs don't cover and rewrites drafts that missed the tone. Memory keeps that work, so the next draft starts from it. It needs the 0002_memory migration and nothing else: no vector database, on Postgres and on D1 alike.

  • Approved answers. On any sent reply, or on what's in the reply box, an agent chooses Save as approved answer. The fast model proposes the question it answers and takes the customer's details out of the text (the names on the ticket are also replaced in code, whatever the model returns); the agent reviews both and saves. From then on it is the drafts' most trusted source, ahead of your articles and cited the same way, and any agent inserts it by typing / at the start of a line in the reply box: no model call, with {name}, {company} and {agent} filled in for the ticket. Answers apply to every language unless you keep one to customers who write in a given language. They are listed, edited and deleted in SupportAiSettings.
  • Learning from edits. When an agent changes a draft before sending it, the draft and what was sent are kept with the customer's question. Later drafts on the same topic or a similar question get them as guidance on tone and structure, never as a source of facts.
  • Guidance suggestions. Once a day, the drafts agents rewrote in the last 30 days are read together. When the same change shows up on at least two tickets, it is proposed as a rule for your guidance, with the reason and the tickets ("In 3 of 5 replies about verification, agents added the onboarding call link"). A person rewords, accepts or dismisses it in the settings. Nothing is added to the guidance without that, and a dismissed rule isn't proposed again.
  • Gaps. Questions that got "no suggested reply", and drafts an agent discarded, are grouped once a day by what was asked: the theme, how many tickets asked, when last, and links to them. Close a gap by writing its approved answer right there, or dismiss it. If a question comes back after its gap was resolved, a new gap opens: the answer isn't working.
  • Articles from solved tickets. Draft an article, on a gap or on a solved ticket's menu, writes a Markdown help article from what your agents actually replied, and says so when no ticket has an answer yet. Copy it into your docs (your knowledge sync brings it back as a source) or save it as an approved answer. Tendkit has no docs CMS and doesn't try to be one.

One customer's ticket never writes another customer's reply

A corrected draft is full of one customer's details, so the two uses are kept apart. Replies sent to the same customer go into that customer's later drafts as they were sent: their data serves them. For everyone else, each correction is first reduced to one sentence about style ("Open with what was already done, then explain the policy"), and that sentence is thrown away unless it passes a check written in code, not in a prompt: no digits, no links or addresses, none of the names on the ticket, and no five words in a row from the conversation. Tickets that aren't linked to a customer only ever get style notes. Set shareStyleNotes: false to keep every correction within its customer.

Earlier corrections are found with a plain word index (support_example_terms), the ticket's topic and the customer, which works the same on Postgres and SQLite. It isn't a full-text extension on purpose: D1 can't export a database that has FTS5 virtual tables, and Drizzle can't generate them. For ranking by meaning, pass an embedder; the candidates are re-ordered in memory and no vectors are stored.

lib/support/index.ts
import { embedMany } from "ai";
import { openai } from "@ai-sdk/openai";

supportAi({
  model,
  store,
  embedder: {
    embed: async (texts) => (await embedMany({ model: openai.embedding("text-embedding-3-small"), values: texts })).embeddings,
  },
});

Style notes are drawn by runScheduled a few at a time with the fast model; the daily review uses the main model for guidance suggestions and the fast one for gaps. None of it runs while a reply is being sent, and a failure is logged and tried again later without touching the inbox. To turn the screens on, expose the methods and add the ones you want to your client:

app/api/support/route.ts
case "ai.listApprovedAnswers":
  return Response.json(await s.ai.listApprovedAnswers());
case "ai.generaliseAnswer":
  return Response.json(await s.ai.generaliseAnswer(input));
case "ai.saveApprovedAnswer":
  return Response.json(await s.ai.saveApprovedAnswer({ ...input, actorId: agent.id }));
case "ai.deleteApprovedAnswer":
  return Response.json(await s.ai.deleteApprovedAnswer(input.id));
case "ai.listGuidanceSuggestions":
  return Response.json(await s.ai.listGuidanceSuggestions());
case "ai.decideGuidanceSuggestion":
  return Response.json(await s.ai.decideGuidanceSuggestion({ ...input, actorId: agent.id }));
case "ai.listGaps":
  return Response.json(await s.ai.listGaps());
case "ai.dismissGap":
  return Response.json(await s.ai.dismissGap({ ...input, actorId: agent.id }));
case "ai.draftArticle":
  return Response.json(await s.ai.draftArticle(input));
app/admin/support/client.ts
ai: {
  // …the methods above. Each of these is optional: the screens show what you provide.
  listApprovedAnswers: () => call("ai.listApprovedAnswers"),
  generaliseAnswer: (input) => call("ai.generaliseAnswer", input),
  saveApprovedAnswer: (input) => call("ai.saveApprovedAnswer", input),
  deleteApprovedAnswer: (input) => call("ai.deleteApprovedAnswer", input),
  listGuidanceSuggestions: () => call("ai.listGuidanceSuggestions"),
  decideGuidanceSuggestion: (input) => call("ai.decideGuidanceSuggestion", input),
  listGaps: () => call("ai.listGaps"),
  dismissGap: (input) => call("ai.dismissGap", input),
  draftArticle: (input) => call("ai.draftArticle", input),
},

Actions

A reply that says "done" beats one that says "we'll look into it". Actions are what your product can do for a customer from a ticket: look up an invoice, resend an email, extend a trial. You declare them in code; AI looks things up while it drafts and proposes changes with the reply; an agent approves each change before it happens. It needs the 0003_actions migration, and zod (or any Standard Schema library) for the inputs.

lib/support/index.ts
import { z } from "zod";
import { supportAi, supportAction } from "@/lib/support/ai";

supportAi({
  model,
  store,
  actions: [
    // A lookup: read-only, so it may run while a reply is drafted.
    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",
      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}`,
          data: { number: invoice.number, total: invoice.total, status: invoice.status, url: invoice.url },
        };
      },
    }),

    // A change: proposed with the reply, run when an agent approves it.
    supportAction({
      name: "extend_trial",
      title: "Extend trial",
      description: "Extends the customer's trial. Use when they ask for more time to evaluate.",
      input: z.object({ days: z.number().int().min(1).max(30).describe("Days to add") }),
      available: ({ customer }) => billing.isOnTrial(customer.id),
      run: async ({ input, customer, agent }) => {
        const until = await billing.extendTrial(customer.id, input.days, { by: agent?.id });
        return { ok: true, summary: `Trial extended to ${until}` };
      },
    }),

    supportAction({
      name: "resend_verification",
      title: "Resend verification email",
      description: "Sends the account's verification email again.",
      input: z.object({ email: z.string().email() }),
      run: async ({ input, customer }) => {
        const sent = await accounts.resendVerification(customer.id, input.email);
        return sent ? { ok: true, summary: `Sent to ${input.email}` } : { ok: false, summary: "That address isn't on this account." };
      },
    }),
  ],
});
NameTypeDescription
name*stringHow the model and the log refer to it: lowercase letters, digits and underscores, unique.
title*stringWhat agents see, such as "Extend trial".
description*stringFor the model: what it does and when to use it.
input*schemaA Zod schema, any Standard Schema, or the AI SDK's jsonSchema(). An object. It is what the model must fill in, what an agent's edits are checked against, and what the form is built from.
approval"always" | "never""never" is for read-only lookups: AI may run them on its own while drafting. Everything else waits for an agent.Default: "always"
portalbooleanFor lookups only: also lets the portal's Ask box run it, for the signed-in customer. run and available then get ticket: null and contact: null, since no ticket exists yet.Default: false
run*({ input, ticket, customer, contact, agent, signal }) => { ok, summary, data? }Does it. summary is one line for the agent and the timeline; data is what a draft may quote. Answer ok: false when it didn't happen; throw only when you can't tell.
available({ ticket, customer }) => booleanWhether it applies to this ticket: a plan, a status, a channel. Defaults to always. ticket is null only for lookups marked portal: true.
timeoutMsnumberHow long a run may take. After that the signal is aborted and the run is logged as timed out.Default: 15000
  • Lookups. While drafting, the model can call your "never" actions through the AI SDK's tool calling, up to six per draft, and state what they returned. Each one the reply relies on is listed under "Based on" with its result, next to your articles and account facts. A lookup that fails tells the model there is no answer, never what was thrown.
  • Proposed actions. When the customer's request calls for a change, the suggestion comes with it: a card under the suggested reply with what it will do, why, and its inputs, which the agent can edit. The reply is written as if it has been done. Approve runs it and records the result on the ticket's timeline, which only agents see. Skip rewrites the reply so it no longer says so; changing the inputs before approving rewrites it to what was actually done. Sending while a proposal is still undecided asks first.
  • By hand. Every available action is also in the ticket's ⋯ menu, with a small form built from its schema: text, number, date, a switch, a select for an enum, and a JSON field for lists and nested objects.
  • Settings. SupportAiSettings lists your actions and how each one runs. The team can stop a lookup from running on its own (agents can still run it). Whether an action needs approval is not a setting: it is in your code, where a change to it is reviewed.

What keeps an action safe

The customer is never an input. An action runs for the customer the ticket is linked to, passed by Tendkit; a ticket from an unknown sender has no actions at all. A message can't run a change. When the model calls an action that needs approval, the call only records a proposal: your run is reachable through runAction alone, which takes the signed-in agent's id from your server. Inputs are checked against your schema when proposed and again when approved. One approval runs once. A double click, or two agents approving the same proposal, get the first run; after a failure, approving again is a new attempt. Every run is logged in support_action_runs: who approved it, the inputs, how it ended and how long it took, including lookups. A run that throws or times out is shown to the agent as unconfirmed, with advice to check the account before trying again, and what was thrown stays in your log.

To turn the screens on, expose the methods and add them to your client:

app/api/support/route.ts
case "ai.listActions":
  return Response.json(await s.ai.ticketActions(input));
case "ai.runAction":
  // The signed-in agent is the approval. Never take actorId from the request body.
  return Response.json(await s.ai.runAction({ ...input, actorId: agent.id }));
case "ai.declineAction":
  return Response.json(await s.ai.declineAction({ ...input, actorId: agent.id }));
case "ai.setActionEnabled":
  return Response.json(await s.ai.setActionEnabled(input));
case "ai.getSettings":
  return Response.json({
    ...(await s.ai.getSettings()),
    sources: await s.ai.listKnowledgeSources(),
    actions: await s.ai.listActions(),
  });
app/admin/support/client.ts
ai: {
  // …the methods above. Each of these is optional: the screens show what you provide.
  listActions: (input) => call("ai.listActions", input),
  runAction: (input) => call("ai.runAction", input),
  declineAction: (input) => call("ai.declineAction", input),
  setActionEnabled: (input) => call("ai.setActionEnabled", input),
},

Autopilot

Some questions get the same correct answer every time. Autopilot lets those be answered in minutes with no person involved, topic by topic, so your team spends its time on what needs judgment. It is never switched on for you: a topic earns it with evidence, a person on your team grants it, and every reply still has to pass each check below. It needs the 0004_autopilot migration, which also files the outcomes you have already recorded under their topics, so a team that has used suggested replies for a while starts with a track record.

The track record

Every suggested reply is stored with how it ended. For each topic, over the last 30 days, Tendkit counts the drafts agents decided on:

  • Sent as drafted. The agent sent it unchanged.
  • Lightly edited. Under 15% of the words changed, and every number, link and address stayed. Changing "3 business days" to "5" is one word and a different answer, so any edit that adds, removes or replaces a number, a link or an email address counts as a rewrite.
  • Rewritten and discarded. The draft wasn't what the agent wanted to say.
  • Came back unhappy. It was sent as drafted or lightly edited, and the customer's next message sounded frustrated or angry, or said the reply didn't help. It counts against the topic.
  • Flagged. Autopilot sent it and an agent marked it as wrong.

Next to those it shows how many questions it couldn't answer (they always go to a person), how many replies Autopilot sent, how many were followed by a reopened ticket, and the split by language. Drafts still waiting, or replaced because the customer wrote again, are no evidence either way and aren't counted.

Eligible, then switched on by a person

A topic is ready when its latest 30 decided drafts are at least 90% sent as drafted or lightly edited, with no flagged reply waiting for review. Both numbers are set in code; in AI settings your team can raise them and never lower them. SupportAiSettings shows every topic with its numbers and a link to the drafts behind them, each with what was sent instead and its ticket.

Ready is an invitation. Someone switches the topic on, choosing how it sends, and their name and the date are kept. setAutopilotTopic refuses a topic that isn't ready, whoever asks.

  • Hold for review (start here). The reply is queued on the ticket, which stays open, and is sent after 10 minutes unless someone steps in: stopping it, answering, leaving a note, or changing the status. A stopped reply stays as an ordinary suggestion. A held reply is taken in one conditional update before anything is done with it, by the scheduler to send it or by an agent who stops it or answers, so the customer never gets two: an agent who replies in the seconds Autopilot is sending is asked to reload instead. Held replies are sent by runScheduled, so the wait is the hold time plus up to one interval of your schedule.
  • Send immediately. The reply goes out as soon as it is written.

What a reply must pass

Autopilot sends a reply only when all of these hold. Otherwise it stays a suggested reply, and says why.

  • The ticket's topic is on, and Autopilot isn't stopped.
  • The ticket is linked to a customer. Unknown senders, contact forms and mail that failed authentication never are.
  • The channel is allowed: every email channel by default, or the ids in channels.
  • The draft is confident and fully sourced: the model marked every statement as backed by a source, every source it cited exists, and at least one of them is an article, an approved answer, an account fact or a lookup. Guidance alone is tone, not an answer.
  • Nothing on the ticket reads like an attempt to steer the assistant ("ignore your instructions", a request for another customer's data), and the reply doesn't recite your guidance. Either one leaves the draft to an agent as not_confident. A false alarm costs one review.
  • It proposes no action. Changes to an account always wait for an agent.
  • The customer doesn't sound angry and the ticket isn't urgent (blockMoods, blockUrgencies).
  • The customer hasn't asked for a person, and hasn't said the last reply didn't help.
  • It isn't the customer's third message since the ticket was opened or reopened.
  • The topic has at least five decided drafts in the customer's language, at the same rate.
  • Fewer than 30 replies went out in the last hour.

A reply asked for by an agent (ai.draft) is theirs to send, and "I can't answer this" is never sent.

What the customer and the team see

The reply goes out from your team, to the people a reply would go to by default. It never signs as a person. By default it ends with your fromName and one line: "This reply was sent automatically. If it doesn't help, reply to say so and a person will take over." That line is true by construction: a customer who says so, or comes back sounding unhappy, is never answered by Autopilot again on that message. Set signature to word it your way or in the customer's language, or to false if you tell customers some other way. The signature is left off when a channel has no room for it.

Agents see the reply on the timeline as "Autopilot", marked "Sent by Autopilot" with its topic. Customers and the portal never see that mark. The ticket moves to Waiting on customer with its assignee unchanged, and the customer's next message brings it back to Open like any other.

Brakes

  • Flag as wrong. One click, by any agent, under a reply Autopilot sent, with a note if they want to say what was wrong. The topic pauses at once, the ticket goes back to Open, and the question is filed with the next daily review as an unanswered question. The topic stays paused until someone with permission resumes it, which records the review.
  • Automatic pause. When fewer than 90% of a topic's latest 30 drafts are right (judged from 10 drafts on), it pauses: checked before every send and on every scheduled run. It can be resumed once the record has recovered.
  • Stop Autopilot. A kill switch in AI settings that any agent can pull. Nothing more is sent, held replies included; topics keep their switches for when it is started again.
  • An hourly cap across all topics, maxPerHour.
  • Failures fall back. A reply the channel didn't take stays on the ticket as failed, with the draft as a suggestion. If anything in Autopilot errors, the draft waits for an agent.
lib/support/index.ts
supportAi({
  model,
  store,
  knowledge: [docs],
  // Every value is optional. These are the defaults.
  autopilot: {
    minDrafts: 30,          // decided drafts a topic needs before it can be switched on
    minRate: 0.9,           // share of them sent as drafted or lightly edited
    windowDays: 30,         // how far back a track record looks
    holdMinutes: 10,        // "hold for review" waits this long before sending
    maxPerHour: 30,         // across all topics
    blockMoods: ["angry"],
    blockUrgencies: ["urgent"],
    maxCustomerMessages: 3, // the customer's third message without a resolution goes to a person
    minLanguageDrafts: 5,   // drafts a language needs within the topic before it's answered alone
    // channels: ["email"],                         // channel ids; defaults to every email channel
    // signature: ({ language }) => "Acme Support", // what an Autopilot reply signs with
    // canManage: (agentId) => isSupportLead(agentId),
  },
});

Who may switch it on

Stopping, switching a topic off, holding a reply and flagging are open to every agent. Switching a topic on, resuming one, starting Autopilot again and raising the bar go through canManage(agentId) when you set it. Either way, pass the signed-in agent's id as actorId from your server: it is who the decision is recorded under.

To turn the screens on, expose the methods and add them to your client:

app/api/support/route.ts
case "ai.getAutopilot":
  return Response.json(await s.ai.getAutopilot());
case "ai.setAutopilotTopic":
  // Who switched a topic on is recorded. Never take actorId from the request body.
  return Response.json(await s.ai.setAutopilotTopic({ ...input, actorId: agent.id }));
case "ai.setAutopilot":
  return Response.json(await s.ai.setAutopilot({ ...input, actorId: agent.id }));
case "ai.autopilotExamples":
  return Response.json(await s.ai.autopilotExamples(input));
case "ai.holdAutopilotReply":
  return Response.json(await s.ai.holdAutopilotReply({ ...input, actorId: agent.id }));
case "ai.flagAutopilotReply":
  return Response.json(await s.ai.flagAutopilotReply({ ...input, actorId: agent.id }));
app/admin/support/client.ts
ai: {
  // …the methods above. Each of these is optional: the screens show what you provide.
  getAutopilot: () => call("ai.getAutopilot"),
  setAutopilotTopic: (input) => call("ai.setAutopilotTopic", input),
  setAutopilot: (input) => call("ai.setAutopilot", input),
  autopilotExamples: (input) => call("ai.autopilotExamples", input),
  holdAutopilotReply: (input) => call("ai.holdAutopilotReply", input),
  flagAutopilotReply: (input) => call("ai.flagAutopilotReply", input),
},

Methods

support().ai exists only when ai is configured.

NameTypeDescription
getSettings / updateSettings{ guidance, topics, draftOnArrival, selfServe }Team guidance (up to 20,000 characters), up to 30 topics, whether to suggest a reply as messages arrive (on by default), and whether the portal's Ask box answers customers (off by default).
syncKnowledge / listKnowledgeSourcesSync every source now; list each source with its article count and last sync.
draft({ ticketId }) => SuggestionSuggest a reply now, for teams that turned suggestions on arrival off.
refine({ ticketId, text, instruction, language? }) => { text }instruction is "shorter", "friendlier", "formal", "translate" or { custom }. Translate goes to the customer's language unless you pass one.
writeUp({ ticketId, notes }) => { text }An agent's notes as a finished reply, with the main model.
translate({ messageId, to? }) => { text }A message in another language; to defaults to agentLanguage.
transcribe({ audio, language? }) => { text, language }Speech to text with transcriptionModel. audio is a Blob, Uint8Array or ArrayBuffer of up to 10 MB; language is a hint passed to transcriptionOptions. Nothing is stored.
discardSuggestion(id)Records that the agent didn't want it.
listApprovedAnswers() => ApprovedAnswer[]Every approved answer: { id, title, text, language, … }.
generaliseAnswer({ ticketId, text }) => { title, text, language }A reply with the customer's details taken out and a title proposed, for the agent to review. Saves nothing.
saveApprovedAnswer({ id?, title, text, language?, ticketId?, gapId?, actorId })Creates an answer, or changes the one with id. gapId marks that gap resolved. Up to 10,000 characters.
deleteApprovedAnswer(id)Drafts stop using it at once.
listGuidanceSuggestions() => { id, rule, reason, tickets }[]Rules waiting for a decision.
decideGuidanceSuggestion({ id, accept, rule?, actorId })Accepting appends the rule (or your rewording of it) to the guidance and returns the settings. Dismissing keeps it from coming back.
listGaps / dismissGapOpen gaps, most asked first: { id, theme, count, lastSeenAt, tickets }.
draftArticle({ gapId } | { ticketIds }) => { title, markdown }A help article from the answered tickets of a gap, or from up to six tickets you choose.
ticketActions({ ticketId }) => TicketAction[]The actions that apply to a ticket, each with its form fields. Empty when the ticket isn't linked to a customer.
runAction({ ticketId, action, input, actorId, suggestionId?, text? }) => { run, text }Runs an action for the ticket's customer and logs it. With suggestionId it approves a proposal, once. text is the reply box, returned rewritten when the inputs were changed.
declineAction({ ticketId, suggestionId, action, actorId, text? }) => { text }Skips a proposal and returns the reply rewritten without it.
listActions / setActionEnabledEvery declared action as { name, title, description, approval, enabled }; setActionEnabled({ name, enabled }) only accepts lookups.
listActionRuns({ ticketId, limit? }) => ActionRun[]A ticket's log, newest first: action, input, trigger (draft, suggestion or manual), status, summary, approvedBy, durationMs.
getAutopilot() => AutopilotOverviewEvery topic with its stage (collecting, eligible, on, paused), track record, mode and who switched it on, plus the thresholds in force and the kill switch.
setAutopilotTopic({ topic, on, mode?, actorId })Switches a topic on ("hold" or "immediate"), off, or changes its mode. Switching on or resuming is refused unless the record meets the thresholds.
setAutopilot({ stopped?, minDrafts?, minRate?, actorId })The kill switch, and thresholds stricter than the ones in code (minRate is 0 to 1; null goes back to the code's).
autopilotExamples({ topic, only?, limit? }) => AutopilotExample[]A topic's recent drafts with how each ended, the text sent instead and the ticket. only: "changed" leaves out the ones that were right.
holdAutopilotReply({ suggestionId, actorId })Stops a held reply. It stays on the ticket as a suggestion.
flagAutopilotReply({ suggestionId, actorId, note? })Marks a reply Autopilot sent as wrong: pauses its topic, reopens the ticket and files the question as a gap.
draftOpening({ intent, customerId?, contactId?, channelId? }) => { subject, text, sources }The first message of a conversation an agent starts, from one line of intent. Sends nothing. See Start a conversation.
listOutreach / sendOutreach / dismissOutreach / holdOutreachThe Outreach queue: messages proposed from signals, sent or dismissed by an agent (actorId). See Signals.
getOutreachKinds / setOutreachKindEach kind of outreach with its track record, and the switch that lets one send on its own. See Sending on its own.
previewBroadcast / draftBroadcast / sendBroadcastOne message to every customer an acknowledged spike affects. See Incident broadcast.
setInsight({ ticketId, actorId, topic?, urgency? })An agent's correction. Kept on later messages and recorded on the timeline.

reply takes an optional suggestionId and records the outcome. listTickets accepts sort: "priority", which falls back to the longest wait when AI isn't configured.

Privacy

What leaves your servers

With AI on, the conversation on a ticket (customer messages, your replies and internal notes), that customer's account facts, your guidance and your knowledge articles are sent to the model provider you configure, under your own account and its terms. Tendkit has no service of its own and sends nothing anywhere else. So is what a lookup returns (summary and data): return only what a reply may say. Attachments are never sent. Voice recordings go to your transcription provider, or to the browser's speech service when you haven't set one, and are never stored.

Insights, suggestions and their outcomes are stored in your database, in support_ticket_insights and support_suggestions, and are deleted with the ticket. So are the corrections memory keeps (support_examples), a ticket's place in a gap, and the log of the actions run on it (support_action_runs, with their inputs and results). Autopilot adds no new copy of a conversation: a track record is counted from support_suggestions, and support_autopilot_topics holds only each topic's switch and who set it. Approved answers and accepted guidance are your team's own text and stay until you delete them. The daily review sends the month's rewritten drafts, and the questions that went unanswered, to the same provider as everything else. Tell your customers about the provider where your privacy policy requires it, and choose one whose data terms fit your product.