Skip to main content

Setting up a new tenant's knowledge base

How to stand up an isolated agent-native knowledge base (the knowledge-base wiki space) for a new team — one that agents read index-first and that compounds from their work, fully isolated from other tenants by Row-Level Security.

For what the KB is and how it operates, read the agent-native knowledge base concept page first; for the read path agents use, see KB retrieval.

The model: a team that needs its own KB = its own tenant​

  • Tenant = the RLS isolation boundary. A team that needs an isolated KB gets its own tenant.
  • Organisation = a sub-entity within a tenant (ADR-005). Orgs in one tenant share the tenant's RLS scope, so they share one KB.
  • Therefore: isolated KB ⇒ separate tenant, not just a separate org.

Prerequisite — verify RLS is actually enforced (do this first)​

This is the gating step

The entire multi-tenant isolation rests on the app connecting to Postgres as a non-BYPASSRLS role. If the runtime connects as a superuser / BYPASSRLS role, all RLS is inert — one tenant could read another's data, and per-tenant KB isolation is illusory.

Before onboarding any team whose KB must be isolated, confirm the runtime connects as a non-bypass role. If it does not, fix that first. Everything below assumes RLS is genuinely enforced.

Setup steps​

1. Provision the tenant + org (Directory)​

Create the tenant (types: AGENCY / SUPPLIER / PROGRAMME_OFFICE / PLATFORM_OPERATOR), at least one organisation, and an admin user. This is the RLS boundary every later step inherits.

2. Create the knowledge-base space (in the new tenant)​

Use exactly the slug knowledge-base. That well-known slug is what wires the coordinator read path and consult file-back to the space. Mark it agent-owned and give it a description (mirror the platform KB space). One KB space per tenant.

3. Give agents tenant-scoped access​

Access is tenant-scoped either way: the credential carries tenant_id, so every MCP / pt CLI call RLS-scopes to this tenant — a credential for Team A can never read Team B's KB.

Developers and interactive agents — pt login (no key to mint):

export PT_BASE_URL=https://constellation.planetb2b.com/projects
npx pt login

Then, for Claude Code, register the MCP server once per machine from the main checkout (claude mcp add writes Claude Code's config only):

claude mcp add constellation -s user \
-e PT_BASE_URL=https://constellation.planetb2b.com/projects \
-e WIKI_BASE_URL=https://constellation.planetb2b.com/wiki \
-- npx -y tsx "$(pwd)/tools/mcp-server/src/index.ts"

Claude Desktop (claude_desktop_config.json), Codex, Cursor, Aider, and hosted (Streamable HTTP) clients each have their own registration — see the MCP server setup for the canonical per-runtime detail.

CLI / stdio / direct-API CI or headless only — where browser consent is impossible — provision the integration's SERVICE principal and have an authorised operator issue its fixed- 90-day key through POST /api/auth/api-keys/service. Store the one-time response as PT_AUTH_TOKEN; it takes precedence over the pt login store, so never leave one set on a developer machine. The employee Settings page self-issues for the signed-in human and is not a CI path. A hosted HTTP connector uses connector OAuth after OAUTH_ENFORCE_AUD=true; it rejects both employee and SERVICE Directory keys because their audience is not MCP_RESOURCE_ID. See CI credentials and expiry.

4. Wire the environment​

VariableWhereWhy
WIKI_BASE_URLeach developer's MCP registration (claude mcp add … -e WIKI_BASE_URL=…) / the host's own configRegisters the direct wiki MCP tools (search_pages, get_page, ingest, lint, …). Does not gate query_knowledge_base — that tool is always registered against the PT client and reads the wiki through PT's WIKI_ZONE_URL.
WIKI_ZONE_URLthe PT / coordinator runtimeThe backend read path for query_knowledge_base and the coordinator KB reader; the wiki-spaces proxy fails closed if it is unset (returns WIKI_BACKEND_NOT_CONFIGURED, never a silent empty).
CRON_SECRET + DISPATCHER_DATABASE_URLthe wiki runtime (for file-back)The outbox dispatch-events cron must run for consult file-back to deliver. Use a dispatcher-role DSN.
KB_READS_EMITTER_SECRETboth the PT runtime and the wiki runtime — same valueAuthenticates KB usage-read recording (wiki.kb_reads). Two-sided — see the callout below. Without it the KB works normally but records nothing.
KB_READS_HASH_SALTthe wiki runtimeKeys the HMAC question hash. Also gates recording: while it is unset, above-UNCLASSIFIED reads are not recorded at all (only UNCLASSIFIED ones are), so recording can be half-dead even with the emitter secret correct.
KB_USAGE_MAX_WINDOW_ROWSthe wiki runtime (optional)Ceiling on how many wiki.kb_reads rows the kb_usage report will aggregate in one window (default 15,000). Above it the report refuses the window with an explicit error naming the row count and a smaller window to retry — it never runs the expensive aggregation and never returns a bare 502 (PLT-755). Raising it does not add capacity — the transaction budget is fixed, so a higher ceiling only admits windows that may then time out, which is the opaque failure the ceiling exists to replace. The default is NOT currently calibrated to production, and knowing that is the point of this sentence. It was sized at roughly half the transaction budget on timings measured against a local database (PLT-755 spec § Measurement); PLT-1321 then measured the deployed path and found capacity between 1,226 and 2,439 rows — roughly 6x below the ceiling — so the breaker never fired and every overrun took the slower residual-timeout path instead. PLT-1321 made the report's two citation facets ~3x cheaper, which moves capacity but not to a figure anyone has measured yet; re-deriving both this ceiling and a possible companion limit on array volume is a post-deploy step on that ticket. Until it lands, treat the default as an upper bound that may not bind. If a legitimate window is being refused, the answers are to scope the report to one space, make the report cheaper, or raise the budget — raise this only with a measurement showing the larger window actually completes. An unset or invalid value falls back to the default rather than disabling the ceiling.
KB_READS_EMITTER_SECRET is two-sided — one side is the same as neither (INF-295)

KB usage recording spans two separate deployments: project-tracker sends the secret in the x-kb-reads-emitter header, and the wiki validates it. They must carry the same value, and all three failure modes are equally silent:

MisconfigurationWhat happens
Unset on PTemitKbUsageRead returns early after one console warning per process lifetime — recording is off for every read on the deployment
Unset on the wikiEvery emit gets a 503 KB_READS_EMITTER_NOT_CONFIGURED
Set to different valuesEvery emit gets a 403 — and the PT side swallows it

The emit is fire-and-forget and post-response (deliberately — a KB read must never fail or slow because telemetry failed), so none of these reach a caller. In every case no new rows reach wiki.kb_reads, and what kb_usage shows depends only on what was recorded before recording stopped:

  • Never configured — the report is all-zero, indistinguishable from "no traffic". That is exactly how the 2026-07-20 incident went unnoticed from release until a human traced the code.
  • Configured, then broken (secret removed, rotated on one side only, or the two sides drifted apart) — rows already inside the 1–90 day report window keep being counted, so the report stays non-zero and looks healthy while recording is completely dead. It goes quiet only gradually, as the last good rows age out of the window. A non-zero kb_usage report is therefore not evidence that recording is currently working — the counts may be entirely historical.

After setting it, redeploy both projects with a genuine full build — a plain Vercel redeploy can miss newly-added env vars (PLT-361). Then verify that the count moves — a moving count is the only check that distinguishes live recording from stale rows:

Poll for the increase — do not read the count once. The usage emit is scheduled through Next.js after(), so query-kb returns to you before the wiki write happens. Reading kb-usage immediately races the emitter and can show an unchanged count on a perfectly healthy deployment — a false alarm that sends you looking for a config bug that isn't there.

npx pt kb-usage --days 1 # note the count
npx pt query-kb "verification probe"

# then poll, up to ~30s, until it rises
for i in $(seq 1 10); do
sleep 3
npx pt kb-usage --days 1
done

(The MCP equivalents are kb_usage(days: 1) and query_knowledge_base(...).) Treat it as failed only if the count never rises across the whole poll window. Run the probe as an UNCLASSIFIED caller, or set KB_READS_HASH_SALT on the wiki first — above-UNCLASSIFIED reads are not recorded while that salt is unset, so the count would stay flat for a reason unrelated to the emitter secret.

None of this is detected automatically yet. Making the unset-secret cases detectable — a wiki-side lint finding, a PT-side check, and a recording-health banner on the report — is rolling out under INF-295 and all three are still pending; until they ship, the two-sided secret has to be checked by hand. The mismatch case stays undetectable even after they land, because neither side can see the other's value; closing it needs a wiki-side rejected-emit counter, tracked as a follow-up.

5. Set the coordinator profile + initiative (PT)​

The coordinator is initiative-scoped. Create the team's initiative and its coordinator_profile (the conventions the coordinator reasons with). The platform coordinator service is generic — per-team behaviour lives in this profile.

6. The per-repo convention layer (in the team's repo)​

The platform is generic; convention lives in two places — the per-initiative coordinator_profile (step 5) and the team's repo:

  • MCP access — each developer runs npx pt login once (OAuth; no token to distribute) and, for Claude Code, registers the server user-scoped with PT_BASE_URL and WIKI_BASE_URL via claude mcp add (Claude Desktop, Codex, and other runtimes use their own config). query_knowledge_base is always registered and reads the wiki via PT's server-side WIKI_ZONE_URL, so omitting your local WIKI_BASE_URL does not stop it working — what goes missing are the direct wiki tools (get_page, search_pages, …). A SERVICE-principal PT_AUTH_TOKEN (CLI/stdio/direct-API CI/headless) or a PT_AUTH_COOKIE (local dev against a logged-in browser / mock-auth session) both take precedence over the OAuth store, so a leftover one silently shadows the login. Employee self-issuance is not an automation setup path.
  • The consult-the-kb skill — so agents query the KB before implementing and cite what they used.
  • .claude/agents/pt-coordinator.md — copied in, with their initiative ID.

A fresh KB is empty. Seed the team's durable canonical knowledge (their constitution / ADRs / conventions) via ingest_source (human-approved, with approvedBy), in curated waves — not a raw dump. ADRs and locked decisions are the best seed material; volatile documents create curation churn. See the knowledge-base concept page for what makes a good ingestion candidate.

Verify it works​

  • Read path: a live consult on the team's initiative returns citations referencing seeded KB pages.
  • File-back loop: a successful UNCLASSIFIED consult files back a synthesis-consult-* page (and events.deliveries grows — proving the dispatcher delivers). Choose the test question with care, because file-back is gated: ask something durable ("why is X built this way", "what is the convention for Y") that the answer can cite a seeded page for. A planning or status question ("what should I work on next") is classified ephemeral, and an uncited answer fails the quality gate — neither writes a page, and neither means the setup is broken. A near-duplicate of an existing synthesis is skipped as well. (Cycle-close retrospectives are the one exception — they file back uncited; see Knowledge base.)
  • Index health: the space index is a small hub-and-spoke, under the coordinator reader cap.
  • Isolation: a token for this tenant cannot see another tenant's KB (the RLS check from the prerequisite, now proven end to end).

Keep it healthy (curation cadence)​

  • Daily mechanical lint — automated cron: orphan / broken-crossref / provenance-drift.
  • Per-release LLM judgement pass + nomination-queue processing: stale-claim / contradiction findings + ingesting newly-nominated pages.
  • See Wiki lint & curation and the wiki-curator subagent.

What is not shared across tenants (yet)​

Each tenant's KB is fully isolated. Common knowledge — a shared constitution, platform ADRs, cross-team conventions — is duplicated per tenant today; there is no shared/federated KB. A cross-tenant / platform shared KB is a future enhancement, not a current capability.

Deployment models​

ModelTrade-off
(a) Additional tenant, shared instanceFastest. The team is a tenant with its own KB. Works today — modulo the RLS prerequisite above.
(b) Separate Constellation deploymentHeavier, full infra isolation. SaaS / Dedicated-Cloud / On-Prem tiers; each deployment gets its own KB. See Deployment.