Teaching it — from a ticket, from an agent's reply, and by hand
The four ways an article gets written — Teach from this ticket, the gap drafter, the nightly harvest and answering a gap inline — what each costs, and why the three machine-written ones all land as a DRAFT.
The assistant answers from articles you wrote. Writing them from a blank page is the slow way, and it is not the way most of them should get written: your team has already answered these questions, in tickets, in their own words, about your own platform. Three mechanisms turn that into reviewable articles, and one of them costs nothing at all.
Four ways an article gets written
| Route in | What it costs | Lands as | Where |
|---|---|---|---|
| Teach from this ticket | One model call, per press | DRAFT |
Live Inbox, case pane |
| Draft answers | Two model calls per gap | DRAFT |
Gaps |
| The nightly harvest | Nothing — no model call | DRAFT |
Runs itself |
| Answer a gap inline | Nothing | PUBLISHED |
Gaps |
ingestArticles selects status: "PUBLISHED". A DRAFT is invisible to
retrieval, cannot be quoted to a customer and cannot be served by the
direct-answer path, no matter which of the three drafting mechanisms produced
it. That is the mechanism enforcing "a human reads it first" — not a convention.
An article with a blank answer is skipped even when published.
Teach from this ticket
Open a conversation in the Live Inbox (/admin/ai/support/inbox), then the
Case tab of the right-hand pane — which is the whole pane on a conversation
the assistant never touched, because the tab bar only appears once there is a
brief or evidence to put beside it. The control is at the bottom, under
Knowledge, and the button is labelled just Teach. It is hidden — not
disabled — for anyone without create.ai.support.knowledge.
It does not file the ticket. It extracts the general case — "when a deposit is missing on a chain that needs a memo, check the memo first" — and throws the transcript away. A future reader must not be able to tell which ticket produced the article.
Four defences, and none of them is sufficient alone
- The conversation is redacted before the model sees it. Emails,
transaction hashes, EVM/Bitcoin/Tron/Monero/Solana addresses, UUIDs, card-like
digit runs, phone numbers, IPs and anything that looks like a seed phrase are
replaced with a label —
[email],[address],[transaction]— rather than deleted. "Send to [address]" is still a usable sentence for pattern extraction; "Send to" is not, and the model will invent the missing half. The agent's real name is dropped too. - The prompt demands the general article and forbids quoting specifics. No name, no amount, no balance, no date, no address, no reference, no ticket number — and placeholders are explicitly named as redactions the model must not restate or guess at.
- The result is created as
DRAFT. NeverPUBLISHED, whatever the model returns. - A human reads it before anything is indexed. Which is defence 3 with teeth: a draft is not in the corpus.
Each of the first three can be defeated on its own. A redactor cannot catch a customer who spells their address out in words; a prompt is an instruction, not a guarantee; and a draft nobody reads is a draft nobody checked. They stack because they fail differently.
The response tells you what was stripped
The redaction counts come back with the draft, and the toast says so — "six identifiers removed". That is deliberate: an operator sending a customer conversation to a model provider should be able to see that six addresses were stripped rather than trust that they were.
reusable: false is a normal outcome, not a failure
Most conversations teach nothing generalisable — a one-off account correction, a duplicate, an apology, pure chat. When the extraction decides that, you get "Nothing reusable here — this conversation was specific to one account, so no article was created" and no article. That is the correct answer. A wrong article is worse than no article, because it will be retrieved with total confidence.
The same happens when the model returns nothing usable. Both cases still report the redaction counts, and both are billed in full — the call was made and it decided the conversation taught nothing.
It runs the answering model over a whole transcript at 1,500 output tokens,
and anyone with create.ai.support.knowledge can press it on as many tickets as
they like.
The daily and monthly caps are checked before the call and it is refused with a 429 when either is reached — and also, less obviously, when a cap is set but the provider prices nothing, because a ceiling that cannot be measured is refused rather than assumed safe. This route reports all three with the same sentence, "The AI budget for this period has been reached"; if your spend is nowhere near the cap, read Cost and budgets on unpriced models. That check now means something on the second press as well as the first: the call is filed against the same ceiling it consults, as a turn on a per-administrator ledger titled Articles written from tickets. It used to write nothing, so its spend was not merely uncapped but invisible.
What is filed is the generalised draft, never the conversation. The transcript is the one thing this route exists to throw away, and a spend ledger is not a place to put it back.
A ticket with fewer than two messages is refused outright.
The nightly harvest
aiSupportHarvestAnswers runs every 24 hours. It is listed in the cron console
as Harvest Answers From Agent Replies.
It makes no model call at all. It is SQL and token overlap: for each gap
cluster it finds the escalated ticket whose customer asked that question, takes
the reply a human agent typed, and files it as a DRAFT — unedited apart from
redaction. Nothing rewrites the sentences; the same redactor Teach from this
ticket uses runs over the reply on its way out, because on this path there is
no model in the loop at all and it is the only automated defence left.
That asymmetry is the whole reason it is separate from the gap drafter. The harvestable half of the loop is free, so it runs unattended; the expensive half stays a button you press and watch.
| Rule | Value | Why |
|---|---|---|
| Population | Sessions in HUMAN_REQUESTED, HUMAN_ACTIVE or AWAITING_USER, most recent 400 |
Where a human necessarily wrote the answer |
| Which message | The last agent reply with ai false |
An escalated ticket often has the AI's failed attempt, then a holding line, then the real answer |
| Which question | The customer's last message before that reply | Not the subject (three words) and not their last message (usually "thanks") |
| Reply length | 80–3,000 characters | Below is an acknowledgement; above is a conversation, and generalising it would be a rewrite |
| Question overlap | 75% of the gap's distinct terms | A loose match files an answer to a different question, which is worse than filing nothing |
| Per night | 20 clusters | A review ceiling, not a cost ceiling — a hundred a night is how a review queue gets abandoned |
It will not write prose, and it never publishes. One ticket can only ever become one article: a reply that satisfies four neighbouring gaps would otherwise appear four times under four titles, which reads as the feature being broken.
When it files an article it marks every member of the cluster DRAFTED, not
just the seed. The gap rows survive with their own counts; only their status
moves.
Drafting from the gap list
On /admin/ai/support/gaps, Draft answers runs the batch drafter over the
ten highest-count open gaps.
The naive version of this — "ask the model to answer each gap" — is a machine for generating confident, ungrounded, plausible text that is then indexed at operator weight and outranks the real documentation. So each gap is re-retrieved first, against the corpus as it stands today, and an answer is drafted only where the documentation now supports one. That is not hypothetical: gaps accumulate over weeks while you add articles and enable products, so a question that failed in week one is often answerable by week three.
Each gap ends in one of three outcomes:
| Outcome | Meaning | generatedBy |
|---|---|---|
drafted |
The documentation supported an answer, or a colleague's reply was found | gap_draft or agent_reply |
needs_operator |
Only you can answer this | — |
failed |
One gap threw; the batch continued | — |
needs_operator carries a reason, and the three are different problems:
no_documentation— retrieval is still below the floor. Nothing to draft from.operator_policy:<topic>— the question is about your own fees, limits, processing times, countries or refunds, and no article of yours covers it. This can never be closed by a documentation page.model_not_grounded— the model read the passages and said they do not answer the question. Its own "no" is respected; it is cheaper to trust than to publish an invention.
Before giving up on model_not_grounded, the route looks for an answer a human
already wrote — the same harvest the nightly job runs, for this one gap. When it
finds one, the reply is carried through unrewritten — redacted, but not
reworded by any model — and filed as agent_reply. The two provenances need two
different reviews: a gap_draft was written from the documentation and needs
checking for invention; an agent_reply was written by a colleague for one
customer and needs editing for a general audience.
The batch's own result line tells you which of the two you are about to read, and what came out of it — "from Sarah's reply — redacted [address]×2; generalise before publishing". A draft that needed two addresses scrubbed was written about one incident, and that is the one to edit rather than rubber-stamp.
The batch is refused outright when the daily or monthly cap is already reached, and also when a cap is set but the provider reports no cost — "A spend cap is set but this provider reports no cost, so the cap cannot be enforced. Price the endpoint or set the caps to 0." See Cost and budgets.
Past that gate it keeps its own running total against what is left of today's
allowance and stops early rather than running the day's budget out on drafts
nobody has read — the response says Stopped early: this would have run past today's budget. The retrieval each gap pays for counts towards that total as
well as the drafting call, because retrieval reranks with a model and that is
billed too.
Unlike Teach from this ticket, these calls still record no turn row, so the cost line in the response is the only place you will ever see what the button spent, and it will not appear in the spend figure the caps are computed from. Read it.
limit is clamped to 1–25, defaulting to 10.
Reviewing and publishing a draft
Drafting moves a gap from OPEN to DRAFTED, so the drafted questions leave the
gap list. They reappear as a review queue on the same screen.
Ordered by the gap's count, so the answer twenty people are waiting for is
reviewed before the one somebody asked once. A gap whose article has been deleted
is reported as orphaned rather than dropped — silently omitting it would
strand the gap in DRAFTED for ever, invisible on both lists.
-
Read the draft against the question. An
agent_replywill still contain one customer's circumstances; that is what you are editing out. -
Publish, and the draft that is already attached is the article. The gap route updates it in place rather than creating a second article saying the same thing. Two near-identical articles is not cosmetic: BM25 retrieves both, they occupy two of the six passages the model is given, and the day one of them is corrected they start contradicting each other in your own voice.
-
The index is rebuilt immediately. The next customer to ask gets the answer within seconds, not after the nightly job.
Two options on that call are worth knowing:
questionoverrides the article's title. It defaults to the customer's own phrasing, which is usually right — it is what the next customer will type and what BM25 has to match.addToQuickRepliesalso offers the question as a one-tap starter under the chat greeting. It is opt-in and offered only here, because this is the one moment the objection disappears: the question is real and it now has a published answer behind it. The list is capped at six, deduplicated case-insensitively, and the response tells you honestly whether it actually grew.
Discard deletes the draft article and puts the question back on the gap list
with articleId cleared, so drafting again starts clean. An abandoned draft
nobody remembers rejecting is exactly the clutter the review queue exists to
prevent.
The article routes
| Route | Method | Permission |
|---|---|---|
/api/admin/ai/support/article |
GET | view.ai.support.knowledge |
/api/admin/ai/support/article |
POST | create.ai.support.knowledge |
/api/admin/ai/support/article/{id} |
PUT | edit.ai.support.knowledge |
/api/admin/ai/support/article/{id} |
DELETE | delete.ai.support.knowledge |
Behaviour worth knowing before you drive them:
- POST accepts
status, and aPUBLISHEDarticle is indexed inline — the whole point of Teach from this ticket is that a human reply becomes reusable knowledge in about fifteen seconds of agent time, which a nightly reindex would break.approvedByis stamped with your user id on publish. - PUT reindexes on every update, publish or not. Publishing with a blank answer is refused: an empty published article produces a confident "here is our refund policy:" followed by nothing.
- DELETE reindexes too. A deleted article that stays in the index keeps being
quoted to customers, which is the worst kind of stale. The row is soft-deleted
(
deletedAt). - Seeded onboarding questions cannot be deleted, and their question text cannot be rewritten. Deleting one would satisfy the autonomy promotion gate by removing the requirement instead of answering it. Clear the answer instead.
The Sources screen shows how many seeded onboarding questions are still unanswered, and Overview lists which ones under the "You have not documented it" group. Neither screen edits an article, and the Gaps screen only publishes articles attached to a gap.
So writing an article for a question no customer has asked yet — including
answering the seeded stubs — goes through POST/PUT on the routes above. The
same applies to a draft from Teach from this ticket: it is attached to no
gap, so it does not appear in the Gaps review queue, and the toast's "Review and
publish it from Knowledge" is pointing at a screen that lists sources rather
than articles. Fetch it from GET /api/admin/ai/support/article and publish it
with the PUT.
If you find a screen for this in a later release, prefer the screen; this is a statement about what the console does today, not a recommendation.
generatedBy is the provenance stamp on the mechanisms that set one, and it
tells a reviewer which of the two reviews to do:
| Value | Written by |
|---|---|
| (empty) | A person, through the article routes or the Gaps screen — and Teach from this ticket, which stamps nothing |
gap_draft |
The batch drafter, from the documentation |
agent_reply |
The batch drafter, falling back to a colleague's reply |
harvest_nightly |
The nightly harvest, from a colleague's reply |
So an empty generatedBy does not mean a human wrote it.
article/from-ticket sets no provenance at all, which means the drafts from the
button this page is built around cannot be told apart from hand-written articles
by that column. What separates them is sourceTicketId: Teach from this ticket,
agent_reply and harvest_nightly all record the conversation they came from,
while a hand-written article and a gap_draft have none.
Why the ticket itself is never indexed
supportTicket.messages carries balances, addresses, transaction metadata and
attachment paths inside free prose, with no field structure to redact against.
Indexing one customer's ticket turns it into another customer's answer.
The reusable value of a ticket is the pattern, not the transcript. Teach from this ticket and the harvest are the two supported ways to extract it, and both of them stop at a draft.
Related: Gaps for the list this all feeds from, Sources for what retrieval can see, How the index is built for the ingest rules, and Permissions for who may press what.