Knowledge base (Claude Agent SDK)
PremiumThe person's answer to the agent's permission request: { id, approved, reason? }.
Preview
It runs with a server and an agent, so there’s no preview here: install it to run it in your app.
Install
First time? Get started sets up the registry and theme. Or with shadcn directly: npx shadcn@latest add @candor/knowledge-base-claude
Brings in: badge, button, card-registry, chat, citation, claude-agent-server, composer, diff, freshness-stamp, input, know, locked, message, prompt-suggestions, prose-diff, relative-time, retrieval-inspector, textarea, thread, use-claude-agent, version-history · npm: @anthropic-ai/claude-agent-sdk@^0.3.318, drizzle-orm@^0.45.4, lucide-react@^1.67.0, postgres@^4.0.0, zod@^4.6.7
<POST>
No props.
Also takes the props of Request.
<GET>
The articles, the gaps, and the drafts waiting, as of now. ?article=KB-001 adds that article in full.
No props.
Also takes the props of Request.
<POST>
What a person does: { action: "edit", id, baseVersion, title, text, note? } publishes their edit as a new version, { action: "review", id, version } marks the published version reviewed, { action: "dismiss-gap", id } closes a gap.
No props.
Also takes the props of Request.
<POST>
The agent, on the Claude Agent SDK. Set ANTHROPIC_API_KEY. Runs on one Node server, not spread over instances.
No props.
Also takes the props of Request.
<ArticlesPage>
People's side: read the articles, edit one (it publishes as a new version), confirm it's still right, and see the gaps.
No props.
<Page>
No props.
<ArticleDraft>
A drafted article change as a read-only word diff. Nothing here publishes it: that's the approval in the chat.
No props.
Also takes the props of DraftCardData.
<ArticleLine>
One article's line: id and published version, owner, and when it was last confirmed right.
| Prop | Type | Default | Description |
|---|---|---|---|
| arequired | ArticleSummary | — | — |
<KnowledgeBase>
The knowledge base: the published articles and open gaps beside a chat with an agent that answers only from those articles, cites them, records what it can't answer, and drafts changes that publish as a new version only after the person approves.
| Prop | Type | Default | Description |
|---|---|---|---|
| chatrequired | KbChat | — | The chat state from useCandorChat (AI SDK) or useClaudeAgent (Claude Agent SDK); the block works with either. |
| apirequired | string | — | The data route the articles and gaps are read from, e.g. "/knowledge-base/api/kb". |
| articlesHrefrequired | string | — | Where people read and edit the articles. |
Functions
connectKbDb(url: string)Open the block's database from a Postgres URL with the postgres-js driver. Swap the driver here (node-postgres, Neon's websocket Pool, …) if your app already uses another; it must support interactive transactions.
drizzleKbRepo(db: KbDb)seedKb(db: KbDb)Load the demo's articles, their versions and the open gaps into the database. Articles and gaps get their ids from identity columns, in this order (KB-001…, G-001…), so the demo loads only into an empty knowledge base and leaves one that has articles alone. Run it once after migrating, e.g. from a script: await seedKb(connectKbDb(process.env.DATABASE_URL!)).
demoKbData()The demo's articles, versions and open gaps, for the in-memory store and for seeding a database (./db/seed.ts).
memoryKbRepo()In this server process's memory: gone on restart and not shared between instances. Transactions run one at a time, and a transaction that throws is rolled back to the state it started from.
termsOf(text: string)A question's or passage's search words: lowercase, without common words, and without a plural "s".
retrieve(question: string, docs: Doc[])The passages that answer question best, highest score first (ties by article and paragraph). Empty when nothing scores.
setKbRepo(repo: KbRepo)Use your own storage (or a database you opened yourself). Call it once, before the routes run.
kbRepo()The storage in use: whatever setKbRepo() was given, else Postgres when DATABASE_URL is set, else memory.
viewerOf(request: Request)Who is asking. Replace with your auth; the demo reads a cookie the articles page sets.
searchArticles(question: string, by: string, searches: Searches)Search the published articles for a question. When nothing is found the question is recorded as a gap here, in code, so an unanswered question is never lost to a model forgetting to record it.
recordGap(question: string, by: string)A question the found passages didn't answer, recorded as a gap.
giveAnswer(input: { question: string; text: string; cites: { articleId: string; version: number }[] }, searches: Searches)Check an answer's citations: each must be an article's published version that one search_articles call in this run found, in searches. The check rests on what search found, never on what the model says it searched for, and the answer card shows the question that search was for.
readArticle(articleId: string)draftArticle(input: { articleId?: string | undefined; title: string; text: string; reason: string; gapIds: string[] }, by: string)A drafted change to an article, or a new article. Nothing is published yet.
publishDraft(id: string, by: string)Publish an approved draft as a new version, if the article hasn't changed since it was written. Older versions are kept.
publishEdit(input: { articleId: string; baseVersion: number; title: string; text: string; note?: string | undefined }, by: string)A person's own edit. It publishes as a new version at once, under the same rule: only against the version they edited.
markReviewed(articleId: string, version: number, by: string)The owner confirms the published version is still right. Only for the version they read.
dismissGap(id: string, by: string)A person decides a gap doesn't need an article.
articleList()The article list: each with its published version, owner, and whether it's due for review.
articleView(id: string, viewer: string)One article as a person sees it: its published text, every version, its open drafts, and why review is locked for them.
resetStore()For tests: start from the seed data again. On a database this deletes the block's tables' rows.
kbToolTitle(tool: string, input: unknown)Plain-words titles for the block's tools, the same for both agent SDKs.
Types
- KbDb
PgDatabase<PgQueryResultHKT, any>- ArticleRecord
- An article: who owns it, which version is published now, and when someone last confirmed it's still right.
{ id: string /** The title made comparable (lowercase, words only). Two articles can't share one. */ slug: string owner: string /** How often the owner should confirm it's still right. Past this it shows as due for review. */ reviewEveryDays: number /** The published version: the one the agent answers from. */ version: number reviewedAt: string reviewedBy: string createdAt: string }- VersionRecord
- One published version of an article. Versions are never changed or deleted.
{ articleId: string; version: number; title: string; text: string; by: string; note: string; createdAt: string; draftId?: string | undefined }- GapRecord
- A question the published articles couldn't answer.
{ id: string /** The question made comparable. Only one open gap per key. */ key: string question: string askedBy: string at: string state: "open" | "answered" | "dismissed" closedBy?: string | undefined closedAt?: string | undefined /** The version that answered it, when a published draft named this gap. */ answeredBy?: { articleId: string; version: number } | undefined }- DraftRecord
{ id: string /** Null for a new article. */ articleId: string | null /** The version the draft was written against (0 for a new article); it publishes only while that's still current. */ baseVersion: number beforeTitle: string before: string title: string after: string reason: string /** The gaps it answers; they close when it publishes. */ gapIds: string[] by: string createdAt: string state: "dra- NewArticleRecord
Omit<ArticleRecord, "id">- NewGapRecord
Omit<GapRecord, "id" | "state" | "closedBy" | "closedAt" | "answeredBy">- NewDraftRecord
Omit<DraftRecord, "id" | "state" | "publishedAs">- ArticlePatch
Partial<Pick<ArticleRecord, "slug" | "version" | "reviewedAt" | "reviewedBy">>- GapPatch
Pick<GapRecord, "state" | "closedBy" | "closedAt" | "answeredBy">- DraftPatch
Partial<Pick<DraftRecord, "state" | "publishedAs">>- Doc
{ articleId: string; version: number; title: string; text: string }- Passage
{ id: string; articleId: string; version: number; title: string; excerpt: string; score: number; matched: string[] }- Article
ArticleRecord- Draft
DraftRecord- Gap
GapRecord- GapCardData
{ id: string; question: string; isNew: boolean; reason: string }- Searches
- What search_articles found during one agent run, kept by the route for that run. give_answer may cite only these, so the model can't make an article citable by choosing what to search for after the fact.
{ question: string; found: Passage[] }[]- AnswerCardData
- What the answer card shows. Built here, so the sources and what was retrieved are the service's word, not the model's.
{ question: string; text: string; sources: MessageSource[]; passages: RetrievedPassage[]; articles: KnowSource[] }- DraftCardData
- What the draft card shows: the change as a diff against the version it was written for.
Pick<DraftRecord, "id" | "articleId" | "baseVersion" | "beforeTitle" | "before" | "title" | "after" | "reason" | "gapIds">- ArticleSummary
Awaited<ReturnType<typeof articleList>>[number]- ArticleView
Awaited<ReturnType<typeof articleView>>- KbSnapshot
- What GET /api/kb returns (without an article).
{ viewer: string; articles: ArticleSummary[]; gaps: Gap[] }- KbChat
- What either adapter hook returns (useCandorChat or useClaudeAgent): the block works with both.
{ messages: MessageData[] composer: React.ComponentProps<typeof Composer> approvals: { id: string; tool: string; input?: unknown; title?: string | undefined; reason?: string | undefined }[] respond: (id: string, approved: boolean, reason?: string) => unknown error?: string | undefined }