Review and curate content
The self-service AI agent flags a conversation with conv_request_handover_in_my_conversation whenever it can't answer from the KB. A human (or another admin agent) then takes over and replies. That reply is the durable answer, but today it stays trapped in one conversation — the next end-user with the same question hits the same dead-end. Your job in a curation pass is to turn those (question, human-reply) pairs into KB documents so the agent can answer them next time.
This skill walks through one pass. It supports two modes:
- Per-conversation mode — the user prompt names a single
conversationId(e.g. "Run a KB curation pass for conversation ccv_xxx"). Skip Step 1 entirely and go straight toconv_get_conversation(<id>). Apply Steps 2–5 to that one conversation only. This is what fires from the agent sidecar on everyconversation.handover_resolvedevent — a (question, human-reply) pair just landed and we want to capture the answer in seconds, not next-week. - Batch mode (no
conversationIdin the prompt) — run Steps 1–6 over the last 7 days of resolved handovers. Used as a weekly safety-net sweep to catch anything missed while the sidecar was offline, and for ad-hoc operator-initiated runs.
Both modes share Steps 2–6 below. Don't refile a candidate that's already in kb-curation-inbox for the same source conversation — kb_propose_curation_candidate tags candidates with source:<conversationId>, so check kb_list_documents({ tag: "candidate" }) and skip pairs whose source you've already filed.
TL;DR
- List recently-resolved handovers with
conv_list_conversations, then narrow to the ones that needed human attention but no longer do. - Read each conversation's messages with
conv_get_conversationand pull out the (end-user question, human-reply) pair. - Skip duplicates and fluff. If a candidate is functionally identical to one you've already filed, skip. If the human reply is a one-off ("yes", "ok"), skip.
- Draft each candidate as a short FAQ-style markdown doc — plain prose, no headings (the
subjectis the title). PasssourceConversationIdandproposedTargetSpaceSlugas structured fields so the review UI can surface them. - File each candidate with
kb_propose_curation_candidate. They land in thekb-curation-inboxKB space (admin audience only — never visible to end-user agents). - Promote approved candidates with
kb_publish_curation_candidateonce a human has reviewed them. That moves the doc into the org-facing space and removes the candidate from the inbox.
Step 1 — list candidates
// MCP call
{
"name": "conv_list_conversations",
"arguments": {
"status": "closed",
"limit": 100
}
}
The tool returns a page of ConversationSummary rows. For each row, the needsHumanAttentionAt field is set whenever the conversation was ever flagged for handover, even if the flag has since been cleared by the human reply. That's the signal you want: filter to rows where needsHumanAttentionAt !== null and the conversation is now status: 'closed' (or open with an assigneeUserId set, meaning a human is actively working it).
If you want to scope to a window (recommended), pass since (ISO timestamp) and only consider conversations whose lastMessageAt is within the window. A weekly pass typically covers the last 7 days.
Step 2 — read each pair
{
"name": "conv_get_conversation",
"arguments": { "id": "ccv_…" }
}
The response includes the full messages[] array. The pattern you're looking for:
- One or more
authorType: "end_user"messages — the question. - An
authorType: "agent"message that contains text like "let me flag this for a teammate" or actually called handover — the gap signal. - One or more later
authorType: "user"(human staff) or"agent"(admin agent) messages — the answer.
Treat the last cluster of human/agent replies as the canonical answer for that gap. If a conversation has multiple unrelated questions, file multiple candidates from the same conversation.
Step 3 — what to skip
- One-word answers. "Yes." / "Sure." / "OK" — not enough signal to make a KB doc out of.
- Customer-specific answers. "Your account is locked because we flagged a chargeback last week" — applies to one end-user, not the population. Don't generalize private state into KB.
- Already-answered. Before filing, call
kb_searchwith the question's gist. If a doc withaudiencesincludingself_servicealready covers it, the gap was elsewhere — maybe the agent's prompt, maybe the doc's discoverability. Don't file a duplicate. - One-off operational state. "We're down for maintenance until 3pm" is not a curation candidate; it's a status update.
Step 4 — draft the candidate
Keep candidates short, FAQ-shaped, and channel-agnostic. Aim for 100–300 words.
Formatting rules — strict:
- Put the question in the
subjectargument (not in the body). The UI renderssubjectas the candidate title. - The body is plain prose. Use bold and italics sparingly to highlight key terms. Bullet lists are fine for 2–5 short items. Inline
codeis fine for product names, IDs, or commands. - No headings. Do not use
#,##, or###anywhere in the body. The candidate already has a title (thesubject) — a heading inside the body just duplicates it and looks bad in the review UI. - No JSON-escaping the body. Pass real markdown with real newlines. Do not stringify the body so it ends up containing literal
\ncharacters — the tool argument is already a string; just send the string. - No tables, no images, no HTML. KB docs render across channels (chat, email, voice TTS) and rich blocks don't survive every channel.
Suggested shape:
[Direct answer in 1–3 sentences.]
[Optional: 2–4 bullet points of relevant detail.]
You don't need to add a "Drafted from conversation …" footer — the system stores sourceConversationId as a structured field and surfaces it in the review UI.
Step 5 — file the candidate
{
"name": "kb_propose_curation_candidate",
"arguments": {
"subject": "Weekend opening hours",
"draftBody": "We're open **10–16 on Saturdays** and 12–16 on Sundays. The downtown branch keeps weekday hours every day.",
"sourceConversationId": "ccv_…",
"proposedTargetSpaceSlug": "support-faq"
}
}
Behavior:
- The first call ever materializes the
kb-curation-inboxKB space (admin audience). Subsequent calls reuse it. - The candidate is created as a regular
kb_documentsrow inside that space, taggedcuration+candidate, audienceadminonly. It is not visible to end-user agents — they keep getting handovers for the same gap until the operator promotes the candidate. - A
kb.curation_candidate.proposedrealtime event fires for any subscribed agent or scheduled runner.
Step 6 — review and promote (the operator's loop)
After your pass, the operator reviews the inbox. They can list candidates with:
{
"name": "kb_list_documents",
"arguments": { "tag": "candidate" }
}
Read each one (kb_get_document), edit if needed (kb_update_document), then promote:
{
"name": "kb_publish_curation_candidate",
"arguments": {
"candidateDocumentId": "kdoc_…",
"targetSpaceSlug": "support-faq",
"audiences": ["admin", "self_service"]
}
}
That moves the doc into the target space, drops the candidate tags, and sets the audiences (default ['admin', 'self_service'] so the self-service agent can find it next time). Discarding instead? Just kb_delete_document.
What NOT to do
- Don't auto-promote. A human (or a trusted admin agent acting on their authority) reviews every candidate before it becomes self-service-visible. Letting an LLM-drafted doc go straight to the public KB is how you ship hallucinations to your end-users.
- Don't file candidates from agent-only chatter. If both messages in the pair are from agents (the self-service agent and an admin agent debating internally), there's no human-confirmed answer — skip.
- Don't include private end-user data. Names, emails, account numbers, internal tickets — strip them when drafting. The candidate is general knowledge.
- Don't recreate the same candidate. If you already filed one for this gap in a previous pass and it's still pending review, leave it alone. The operator hasn't gotten to it yet; piling on doesn't help.
Related
skill://kb/create-first-space— populating an empty KB from scratch.skill://conv/escalate-to-human— the symmetric flow from the chat-widget bot's side.