Internal knowledge agent
A retrieval-grounded agent over your private docs — with per-chunk provenance in the audit ledger and a "no chunk, no answer" safety policy.
An internal knowledge agent is the use case that exposes the hidden cost of un-attributed answers. A model that's "pretty sure" is a model that gets cited by an employee in a Slack thread, which becomes a process document, which becomes wrong.
Pleach's answer: every chunk the retrieval tool returned lives in the audit row for that turn. Every answer is replayable against the same chunks. And a safety policy can refuse to answer when no chunk crossed the confidence threshold.
Related shapes. Regulated-domain agent if the corpus contains PHI, PII, or other regulated content. Multi-tenant SaaS agent if one runtime indexes per-tenant corpora. Customer support agent if the retrieved answer feeds a multi-turn support thread.
What you're building
An agent that answers questions about your internal documentation — onboarding wikis, runbooks, design docs. It:
- Retrieves relevant chunks from a vector store.
- Cites each claim with a chunk id and document URL.
- Refuses to answer when retrieval confidence is below threshold.
The retrieval tool's full input and output land in the audit ledger. The answer is reproducible under determinism: same question + same chunks
- same model + same seed = same answer.
The retrieval tool
One tool that the agent calls before composing any answer. The output schema makes provenance non-optional — the model can't return an answer without surfacing the chunks it used.
// lib/tools/searchInternalDocs.ts
import { defineTool } from "@pleach/core";
import { z } from "zod";
const Chunk = z.object({
chunkId: z.string(),
docUrl: z.string().url(),
docTitle: z.string(),
text: z.string(),
score: z.number().min(0).max(1),
});
export const searchInternalDocs = defineTool({
name: "search_internal_docs",
description: "Retrieve up to 8 chunks from internal documentation. Always call this before composing an answer.",
input: z.object({
query: z.string().min(4),
topK: z.number().int().min(1).max(8).default(5),
}),
output: z.object({
chunks: z.array(Chunk),
maxScore: z.number().min(0).max(1),
queryEmbedded: z.array(z.number()),
}),
async handler({ query, topK }) {
const embedding = await embed(query);
// Tenant isolation is enforced at the storage/RLS layer (the runtime's
// `tenantId` scope) — it is NOT threaded through the tool context.
const chunks = await vectorStore.search(embedding, { topK });
return {
chunks,
maxScore: Math.max(0, ...chunks.map(c => c.score)),
queryEmbedded: embedding,
};
},
});queryEmbedded is on the output deliberately — it lands in the
audit row, so a later reproduction can use the exact embedding
vector instead of re-embedding (which would drift if the
embedding model is updated).
The "no chunk, no answer" safety policy
A safety policy that gates the final synthesis on retrieval confidence. If the top chunk's score is below threshold, the runtime forces the model to return the standard "I don't know" template instead of synthesizing.
// lib/safety/noChunkNoAnswer.ts
import { defineSafetyPolicy, safetyPolicyId } from "@pleach/core/safety";
export const noChunkNoAnswer = defineSafetyPolicy({
id: safetyPolicyId("knowledge-agent.no-chunk-no-answer"),
version: "1.0.0",
enforcement: "refusal",
scope: { callClass: "synthesize" },
content: `
[Retrieval confidence policy]
If the retrieval tool returns no chunks with a
score >= 0.6, do not synthesize an answer.
Reply with the standard template:
"I don't have enough confidence in the internal
documentation to answer this. Try rephrasing,
or ask a human."
Cite the empty-result set as the reason.
`.trim(),
});The policy is capability-subtracting: it surfaces the
operator's stated refusal posture (composed LAST into the
system prompt) and lands on the audit row by id + version
so a later review can ask "which turns ran under this rule".
See Safety for the contribution shape.
Runtime construction
// lib/runtime.ts
import { SessionRuntime, AiSdkProvider, definePleachPlugin, appendPrompt } from "@pleach/core";
import { SupabaseAdapter } from "@pleach/core/sessions";
import { SupabaseSaver } from "@pleach/core/checkpointing";
import { createOpenRouter } from "@openrouter/ai-sdk-provider";
const openrouter = createOpenRouter({ apiKey: process.env.OPENROUTER_API_KEY! });
const KNOWLEDGE_SYSTEM_PROMPT = `You answer questions about internal documentation.
Rules:
- Always call search_internal_docs before answering.
- Cite each claim with the chunk_id and doc URL it came from.
- If the tool returns no chunks above 0.6 score, say you don't know.`;
export function buildKnowledgeRuntime(req: AuthedRequest) {
return new SessionRuntime({
provider: new AiSdkProvider({
model: openrouter("anthropic/claude-sonnet-4-5"),
maxSteps: 5,
}),
storage: new SupabaseAdapter({ client: supabase }),
checkpointer: new SupabaseSaver({ client: supabase }),
plugins: [definePleachPlugin("knowledge-tools", {
tools: [searchInternalDocs],
safetyPolicies: [noChunkNoAnswer, piiRedaction],
prompts: [appendPrompt("knowledge-agent.system", KNOWLEDGE_SYSTEM_PROMPT)],
})],
tenantId: req.tenantId,
userId: req.userId,
});
}What the ledger sees
A single turn writes (at minimum) three audit rows:
| Row | call_kind | What it carries |
|---|---|---|
| 1 | llm | The initial planning call — system prompt, user message, model response with the tool call |
| 2 | tool | search_internal_docs input + full output (chunks, scores, embedding) |
| 3 | llm | The synthesis call — input now includes the chunks; output is the cited answer |
Reproducing the answer is row 2's output replayed into row 3's
input. The runtimeMode: "replay"
constructor does this end-to-end.
Provenance query
"Show me every answer this week that cited doc X."
select
turn_id,
user_id,
created_at,
payload->'output'->'finalText' as answer
from harness_auditable_calls
where call_kind = 'llm'
and created_at >= now() - interval '7 days'
and exists (
select 1
from harness_auditable_calls t
where t.turn_id = harness_auditable_calls.turn_id
and t.call_kind = 'tool'
and t.tool_name = 'search_internal_docs'
and t.payload->'output'->'chunks' @> jsonb_build_array(
jsonb_build_object('docUrl', $1)
)
);If doc X was wrong and got rewritten, this query is the list of answers to revisit.
Eval: lock the chunks, vary the prompt
The retrieval output is recorded. Build a fresh session runtime with a new system prompt, replay the recorded turn — the diff tells you whether the prompt change improved synthesis without re-running embedding.
import { createReplayRuntime } from "@pleach/replay";
const NEW_SYSTEM_PROMPT = `…revised instructions…`;
const challenger = new SessionRuntime({
provider: new AiSdkProvider({
model: openrouter("anthropic/claude-sonnet-4-5"),
maxSteps: 5,
}),
storage: new SupabaseAdapter({ client: supabase }),
plugins: [definePleachPlugin("knowledge-tools", {
tools: [searchInternalDocs],
safetyPolicies: [noChunkNoAnswer, piiRedaction],
prompts: [appendPrompt("knowledge-agent.system", NEW_SYSTEM_PROMPT)],
})],
});
const replayRuntime = createReplayRuntime({
sessionRuntime: challenger,
tenantId: req.tenantId,
});
const replay = await replayRuntime.replayTurn({
chatId: sessionId,
tenantId: req.tenantId,
messageId: goldenTurnId,
});
// `replay.state` is the reconstructed HydratedHarnessState (typed `unknown`):
// the re-synthesized answer plus the tool calls, whose chunks came from the ledger.
const state = replay.state;
console.log(state);Project layout
Three adds on top of the baseline:
an index/ module the retrieval tool calls into, a safety/
directory for the "no chunk, no answer" rule, and a SQL file for
the provenance query QA reads.
my-app/
src/
pleach/
runtime.ts # SessionRuntime + tools + safety + storage
tools/
search-internal-docs.ts # defineTool — returns chunks with chunkId + sourceUri
index/
client.ts # the vector / lexical index the tool calls into
ingest.ts # offline corpus ingestion (separate process)
safety/
no-chunk-no-answer.ts # defineSafetyPolicy — refuses synthesis without chunk evidence
pii-redaction.ts # → /docs/scrubbers
app/
api/agents/[id]/route.ts
qa/
provenance.sql # "which chunks did turn T cite?"What changes from the baseline:
index/is separate fromtools/. The tool is a thin wrapper that turns a query into a chunk list; the index is the store. They have different release cadences — the index gets re-ingested on a corpus refresh schedule; the tool only changes when the chunk shape changes.index/ingest.tsis a separate process. Ingestion is long-running and offline; it doesn't share lifetime with the agent runtime. Keep it insrc/pleach/index/so the chunk shape stays consistent between writer and reader, but run it via its own entry point (cron, queue worker, CLI).safety/no-chunk-no-answer.tsis the load-bearing file. This is what prevents fabricated citations — a capability-subtracting safety policy that refuses synthesis if the tool returned zero chunks. A prompt instruction would not be enforceable; a policy is.qa/provenance.sqllives in the repo. The provenance query is what answers "what did the agent cite?" weeks after the turn. Same discipline as the customer-support rollup: the SQL ships next to the code that produces the rows it reads.
Where to go next
Safety
The capability-subtracting policy contract — refuse, rewrite, or annotate, but never generate.
Audit ledger
The harness_auditable_calls schema behind the provenance query.
Fingerprint
What determines whether two calls hash to the same row.
Regulated-domain agent
Pre-dispatch redaction patterns if your knowledge corpus contains regulated content.
Deep research agent
An anchor agent that dispatches bounded subagents, with depth-tracked fanout and a turn-rooted tree you can read top-down from the ledger.
Multi-tenant SaaS agent
The multi-tenant SaaS agent shape — recurring per-tenant pattern, the primitives that handle it, the ops surface around it.