API Reference¶
Base URL: http://localhost:3001 with Docker Compose, where the proxy forwards /api/* to the server. The server itself listens on port 3000, so use http://<host>:3000 when you reach it directly (for example a Kubernetes port-forward).
Most endpoints require authentication via Bearer token or API key:
OpenAPI Specification¶
DocBrain auto-generates an OpenAPI 3.1 specification from all API routes. The spec includes request/response schemas, authentication requirements, and error codes for every endpoint.
| Resource | URL |
|---|---|
| Swagger UI | GET /api/docs |
| OpenAPI JSON | GET /api/docs/openapi.json |
The Swagger UI is publicly accessible (no authentication required) and provides interactive API exploration with "Try it out" functionality. Use the OpenAPI JSON endpoint for client code generation with tools like openapi-generator, oapi-codegen, or swagger-codegen.
The specification is versioned — the info.version field tracks the DocBrain release version.
Authentication¶
Login¶
Exchange email + password for a session token. The token is a db_sk_... API key with a TTL set by LOGIN_SESSION_TTL_HOURS.
Request Body:
Response:
Logout¶
Revokes the current session token. Requires authentication.
Response: 200 OK with {"ok": true}
Verify Auth / Whoami¶
Returns the identity of the current token (API key or session key). Useful for verifying an API key is valid.
Response:
{
"key_id": "uuid",
"name": "Platform Team Key",
"role": "editor",
"allowed_spaces": ["PLATFORM", "SRE"],
"created_at": "2025-12-01T00:00:00Z"
}
Core Endpoints¶
Health Check¶
Returns 200 OK with {"status": "ok"}. Does not require authentication — used by load balancers and container health probes.
Ask a Question¶
Request Body:
{
"question": "How do I deploy to production?",
"session_id": "optional-uuid-for-conversation-continuity",
"space": "PLATFORM",
"spaces": ["PLATFORM", "SRE"],
"stream": true
}
session_id— optional UUID to continue a conversation across turnsspace— soft boost: results from this space get a 1.5× score multiplier but other spaces still appear. Use when you want cross-space results with your team's docs ranked first.spaces— hard filter: only return results from these spaces for this request. If combined with an API key'sallowed_spaces, the intersection is used (most restrictive wins). Omit to search all spaces.stream— iftrue, returns SSE; iffalse(default), returns JSON
Response (non-streaming):
{
"answer": "To deploy to production, follow these steps...",
"sources": [
{
"title": "Deployment Guide",
"heading": "Production Deployment",
"content": "...",
"source_url": "https://...",
"score": 0.92
}
],
"session_id": "uuid",
"episode_id": "uuid",
"turn": 1,
"intent": "procedural",
"picker_trace_id": "uuid",
"user_failed_relevant": [],
"user_unconnected_relevant": ["jira.search"]
}
When live MCP tools are involved, the response carries three optional fields (all omitted when empty / not applicable):
picker_trace_id— present when MCP tools fired for this request. Pass it toGET /api/v1/ask/picker-trace/{request_id}to retrieve the full tool-selection trace (what was considered, selected, rejected, and why). Powers the "Why these tools?" explainability panel in the UI.user_failed_relevant— tool names the picker would have used, but the caller's OAuth token for that integration is expired/failed. Drives a "reconnect" prompt in the UI.user_unconnected_relevant— tool names the picker would have used, but the caller has not connected that integration at all. Drives a "connect this tool" prompt in the UI.
Streaming Response (stream: true):
Returns Server-Sent Events (SSE):
event: phase
data: {"status": "started", "phase": "retrieval", "description": "Searching documents..."}
event: phase
data: {"status": "completed", "phase": "retrieval", "duration_ms": 145, "result_count": 5}
event: token
data: {"text": "To deploy"}
event: token
data: {"text": " to production"}
event: answer
data: {"answer": "...", "sources": [...], "session_id": "...", "episode_id": "...", "intent": "procedural"}
GET /api/v1/ask/picker-trace/{request_id}¶
Returns the tool-picker trace for a recent /ask request — the catalog the
fast LLM saw, the tools it selected and rejected, its 1-sentence rationale,
and the user-specific buckets (user_unconnected_relevant,
user_failed_relevant) used to surface "connect this tool" hints in the UI.
Path params:
- {request_id} — the per-request id returned in the /ask response.
RBAC: any authenticated user. The handler restricts results to the
calling user — cross-user lookups return 404 (existence-disclosure
prevention). Service-account API keys (which have no user_id) get 403.
Response (200 OK):
{
"request_id": "uuid",
"user_id": "uuid",
"considered": [
{ "name": "github.issue_read", "manifest_id": "github" }
],
"selected": [
{ "name": "github.issue_read", "args": { "...": "..." } }
],
"rejected": [
{ "name": "jira.search", "reason": "not_relevant_to_query" }
],
"rationale": "Fetching the linked issue gives the deploy-rollback context.",
"user_unconnected_relevant": [
{ "manifest_id": "slack", "display_name": "Slack" }
],
"user_failed_relevant": []
}
Responses:
- 200 OK — trace returned.
- 403 Forbidden — caller is a service-account API key (no user_id).
- 404 Not Found — request_id unknown, expired, or owned by a different user.
Traces are held in an in-process per-user LRU; they expire after roughly one
hour or under cache pressure. The endpoint is read-only and does not write
to audit_log.
Generate a Document¶
Generate a documentation draft grounded in your org's own knowledge (corpus +
episodes + live MCP connectors), with per-claim provenance. Returns the markdown
— it does not publish. Stateless. Requires editor role (same auth as
/ask).
Each --source-url-style URL source is a named primary source: if any one
can't be fetched, the whole generation aborts (502) rather than producing a
doc from a subset.
Request Body (GenerateRequest):
{
"ask": "runbook for cert rotation",
"sources": [
{ "kind": "file", "label": "notes.md", "raw": "..." },
{ "kind": "url", "label": "https://acme.atlassian.net/wiki/spaces/OPS/pages/123", "raw": "" }
],
"target": "optional existing-doc reference to augment",
"template": "optional raw template file content (the bytes, not a path)",
"doc_type": "runbook",
"space": "OPS",
"no_enrich": false
}
ask— required. The natural-language description of the doc to write.sources— primary material.kindisfile,stdin, orurl. Forkind: "url", the URL goes inlabelandrawis""— the server fetches it via the connected MCP connector (Confluence page, Jira issue, Slack thread, GitHub PR or file).target— augment an existing doc rather than write from scratch.template— the raw template file content (not a path); shapes section structure, block shape (table columns, checklists, code-block language, header fields) and tone only — filled from your sources, gaps markedNEEDS INPUT, example rows/commands never copied. Can never disable a safety or quality rule.space— applies that Confluence space's quality rules;no_enrich: truedisables live-MCP enrichment.
Response (GeneratedArtifact):
{
"markdown": "# Cert Rotation Runbook\n...",
"doc_type": "runbook",
"provenance": [
{ "section": "Overview", "source_ids": ["chunk-1", "chunk-2"] }
],
"needs_input": [ "Which CA issues the production certs?" ],
"needs_input_items": [
{ "heading": "Certificate authority", "reason": "Which CA issues the production certs?" }
],
"skipped_sources": [
{ "label": "jira", "reason": "connector not connected" }
],
"quality": {
"score": 87.0,
"violations": [ { "rule_name": "...", "severity": "warning", "message": "..." } ]
}
}
provenance— per-section attribution:{ section, source_ids }(sectionisnullfor whole-doc).needs_input— questions the doc can't answer from available knowledge (the honesty signal, not a fabrication).needs_input_items— the same gaps asneeds_input, but structured as{ heading, reason }so a UI can render each as a clickable item (jump to the section, or feed it back into a regenerate). Omitted when there are no gaps;needs_inputremains the flat list for back-compatibility.skipped_sources— sources that were unavailable, each{ label, reason }.quality—scoreis a 0–100 number; each violation is{ rule_name, severity, message }.
Error responses:
| Status | Meaning |
|---|---|
400 |
Validation failure / unknown source kind / unsupported or unrecognized URL (incl. a smuggled rule directive in a template). Also returned when target is a fetchable source URL (a Slack thread, GitHub PR/file, Jira issue, or Confluence page) and no other sources are given — target is the existing doc to augment, not source material, so this almost always means you meant to pass it as a source instead. |
403 |
Caller is not an editor |
413 |
Source material over the per-source or aggregate size budget (inline or after fetching links) |
502 |
A named URL source could not be fetched (connector not connected/configured, or fetch error) |
503 |
Generation not configured |
See the Generate guide for the CLI, the template format, and CI playbooks.
Submit Feedback¶
Request Body:
feedback: 1 (helpful) or -1 (not helpful). Negative feedback seeds the Autopilot gap detection pipeline.
Freshness Report¶
GET /api/v1/freshness?space=DOCS
GET /api/v1/freshness?tags=architecture,api
GET /api/v1/freshness?archived=true
Query Parameters (mutually exclusive — archived > tags > space):
- space (optional) — Filter by document space
- tags (optional) — Comma-separated source labels. Returns docs whose source_labels overlap any value (e.g. ?tags=architecture,api)
- archived (optional) — When true, returns docs whose lifecycle_status is non-active (archived / reference / deprecated). Used to populate the "View excluded (N)" modal in the UI.
Response:
{
"space": "DOCS",
"summary": { "total_docs": 142, "fresh": 98, "review": 27, "stale": 12, "outdated": 5, "avg_score": 76.3 },
"documents": [
{
"document_id": "123",
"title": "API Guide",
"space": "DOCS",
"source_url": "https://...",
"total_score": 45.2,
"status": "stale",
"source_labels": ["api", "v2"],
"lifecycle_status": "active",
"time_decay_score": 30,
"engagement_score": 50,
"content_currency_score": 40,
"link_health_score": 60,
"contradiction_score": 80
}
]
}
GET /api/v1/documents/review-priority?picture=1¶
The web console's Documents picture on Home: the pages worth a look and the corpus around them,
drawn on evidence, never on age. Analyst and above; a key limited to some spaces is refused (403),
as for the review-priority list itself. picture=1 is served with the FIRST page only (limit as
usual; with a cursor it is a 400; any value but 0 or 1 is a 400). The response is the
ordinary review-priority response plus picture, and every reach number in it — reach.read_docs,
reach.answered_docs, each row's reach.times_cited and everything in picture — is read by one
statement, so the panel's numbers and the picture's agree.
segmentspartitionsreach.total_docs:wrongandwatch(the scored pages' tiers),clear(reached in the window, not listed, judged by the runchecked_atnames),since(reached, not listed, with no reach inside that run's own window(started_at − window_days, started_at], so the run could not judge it;nullwhen the run did not record its window) andunreached(total_docs − read_docs − listed_unreached).listed_unreached: listed pages no question reached in the window.grid: clear pages no answer cited — drawn as a count, never named.listed: every scored page —document_id,tier,times_cited,title,space,source_url,source_type, andmain_reason(kind,more_kinds, and the superseded counts, without the other page).clear: the cited clear pages, most cited first, at mostclear_cap(5,000) ofclear_cited_total.table:wrong/watch/clear/since(nullwhen not measured), each by citation bucketb0(not cited),b1(1–9),b10(10–99),b100(100+); exact over the scored set, never cut by the dot cap.
No date, age or score is in picture.
Mark a Document Archived (Lifecycle Override)¶
Manually set a document's lifecycle status. Sticky — survives future syncs even if the source-system label changes. Requires admin.
status must be one of: active, archived, reference, deprecated. Setting active re-enables freshness scoring for the doc and triggers a rescore.
Response:
Backfill Lifecycle Across the Corpus¶
Re-derive lifecycle_status for every auto-managed doc from current source_labels and freshness.exclusion_rules config. Run after editing exclusion rules. Manual overrides are preserved. Requires admin.
Response:
Analytics¶
Query Parameters:
- days (optional, default: 30) — Reporting period in days
- space (optional) — Filter most-retrieved docs by Confluence space
- user_id (optional) — Filter query statistics by a specific user UUID
Response:
{
"period_days": 30,
"total_queries": 1247,
"unique_users": 45,
"avg_feedback": 0.82,
"unanswered_rate": 0.12,
"queries_by_day": [
{"date": "2025-01-15", "count": 42}
],
"top_intents": [
{"intent": "procedural", "count": 456}
],
"top_queries": [
{"query": "How do I deploy?", "count": 18}
],
"most_retrieved_docs": [
{
"title": "Deploy Guide",
"source_url": "https://...",
"space": "PLATFORM",
"retrieval_count": 94,
"freshness_score": 45.0,
"freshness_status": "stale"
}
]
}
CSV export:
Returns a CSV file of all query episodes in the period. Useful for external BI tools.
Analytics CSV Export¶
Returns a .csv file of all query episodes in the requested period. Columns: episode_id, created_at, user_id, query, intent, feedback, space.
Server Configuration¶
No authentication required. Returns enabled features and server version.
{
"version": "0.6.0",
"features": {
"freshness": true,
"analytics": true,
"slack": true,
"autopilot": true,
"incident_mode": true
}
}
Incident Mode¶
Request Body:
Activates incident mode, which prioritizes retrieval of runbooks and incident playbooks.
Admin Dashboard¶
Single-request admin overview. Returns all key health metrics in one parallel-fetched payload — designed for dashboards that need to avoid multiple round-trips.
Response:
{
"health": {
"total_documents": 342,
"overall_health_score": 67.3,
"freshness_distribution": {
"fresh": 120,
"review": 89,
"stale": 72,
"outdated": 41,
"archive": 20
},
"top_stale_cited_docs": [
{
"title": "Deploy Guide",
"freshness_score": 23.0,
"citations_last_7d": 47,
"contradiction_score": 45.0
}
],
"coverage_gaps": 15
},
"autopilot": {
"total_gaps": 20,
"open_gaps": 14,
"critical_gaps": 3,
"drafts_generated": 8,
"drafts_published": 2,
"last_analysis_at": "2025-02-20T14:30:00Z"
},
"forecast": {
"current_open_gaps": 14,
"projected_new_critical_30d": 5,
"projected_total_30d": 19,
"avg_weekly_new_gaps": 2.5,
"avg_weekly_resolved": 1.0,
"trend": "worsening"
},
"freshness_distribution": {
"fresh": 120,
"review": 89,
"stale": 72,
"outdated": 41,
"archive": 20
},
"top_gaps": [...],
"top_docs": [...],
"recent_audit": [
{
"action": "gap_dismissed",
"entity_id": "uuid",
"actor": "admin@example.com",
"created_at": "2026-02-25T10:00:00Z"
}
]
}
Autopilot Endpoints¶
Requires AUTOPILOT_ENABLED=true. All endpoints require authentication.
Autopilot Summary¶
Response:
{
"total_gaps": 20,
"open_gaps": 14,
"critical_gaps": 3,
"drafts_generated": 8,
"drafts_published": 2,
"last_analysis_at": "2025-02-20T14:30:00Z"
}
Gap Growth Forecast¶
Returns a 30-day projection of gap cluster growth based on the last 4 weeks of creation and resolution rates (linear extrapolation).
Response:
{
"current_open_gaps": 14,
"projected_new_critical_30d": 5,
"projected_total_30d": 19,
"avg_weekly_new_gaps": 2.5,
"avg_weekly_resolved": 1.0,
"trend": "worsening"
}
trend is one of:
- "improving" — resolution rate ≥ 75% of creation rate
- "stable" — resolution rate ≥ 40% of creation rate
- "worsening" — resolution rate < 40% of creation rate
List Gap Clusters¶
Query Parameters:
- limit (optional, default: 20) — Maximum clusters to return
- status (optional) — Filter by status: open, dismissed, resolved
- severity (optional) — Filter by severity: low, medium, high, critical
Response:
[
{
"id": "uuid",
"label": "Production Deployment Process",
"description": "Multiple questions about deploying services to production went unanswered.",
"query_count": 47,
"sample_queries": [
"How do I deploy to prod?",
"What's the canary process?",
"Where are the deployment configs?"
],
"avg_confidence": 0.28,
"severity": "critical",
"status": "open",
"unique_users": 12,
"negative_ratio": 0.68,
"trend": "recurring",
"assignee_id": null,
"assigned_at": null,
"created_at": "2025-02-15T10:00:00Z",
"updated_at": "2025-02-20T14:30:00Z"
}
]
New fields vs. earlier versions:
- unique_users — distinct users who hit this gap
- negative_ratio — fraction of queries on this topic with negative feedback
- trend — "new" (appeared in last 7 days) or "recurring" (open > 7 days)
- assignee_id — UUID of the user assigned to resolve this gap, or null
- assigned_at — ISO timestamp when the gap was assigned, or null
Trigger Gap Analysis¶
Runs gap analysis immediately (normally runs on the AUTOPILOT_GAP_ANALYSIS_INTERVAL_HOURS schedule). Returns the number of new clusters created.
Response:
Dismiss a Gap¶
Marks a gap cluster as dismissed (not worth addressing). Requires admin or editor role.
Assign a Gap¶
Assigns a gap cluster to a user for resolution.
Request Body:
Response: 200 OK with the updated gap cluster object.
Gap Related Documents¶
Returns documents semantically related to this gap cluster — these are the docs that users were trying to get answers from when the gap was detected. Useful for identifying which authors to notify or which content needs updating.
Response:
[
{
"source_id": "doc-123",
"title": "Production Deployment Guide",
"source_url": "https://confluence.example.com/...",
"space": "PLATFORM",
"freshness_score": 23.5,
"author": "author@example.com"
}
]
List Drafts¶
Query Parameters:
- status (optional) — Filter by status: pending_review, approved, published, rejected
- limit (optional, default: 20) — Maximum drafts to return
Response:
[
{
"id": "uuid",
"cluster_id": "uuid",
"title": "Production Deployment Runbook",
"content": "# Production Deployment\n\n## Prerequisites\n...",
"content_type": "runbook",
"source_queries": ["How do I deploy to prod?", "..."],
"source_doc_ids": ["doc-uuid-1", "doc-uuid-2"],
"quality_score": 0.87,
"status": "pending_review",
"created_at": "2025-02-20T15:00:00Z"
}
]
Get a Draft¶
Returns full draft content for review.
A key limited to some spaces lists only the drafts in its spaces (a draft with no space is not visible to a restricted key, even one that lists GENERAL), and gets 404 for any other draft by id — the same as for an id that does not exist.
Generate Draft for a Gap¶
Generates a draft document for the specified gap cluster. Uses existing docs as context. Also DMs Slack authors of related docs if SLACK_BOT_TOKEN is configured.
Response:
{
"draft_id": "uuid",
"title": "Production Deployment Runbook",
"content_type": "runbook",
"quality_score": 0.87
}
Update Draft Status¶
Request Body:
Valid statuses: approved, published, rejected
Publish Draft¶
Publishes an approved draft to the configured target system. Creates a page (Confluence), PR (GitHub), or MR (GitLab) with the draft content.
Query Parameters:
- target (optional) — Override the publish target: confluence, github, gitlab. If omitted, uses per-space routing (DB) → default config target.
Response:
{
"draft_id": "uuid",
"status": "published",
"target": "github",
"url": "https://github.com/acme/docs/pull/42"
}
Auth: Editor role or above.
Doc-Improvement Evidence¶
Returns the labeled evidence chains for auto-published fixes plus an honest aggregate. Each chain reports how far a fix progressed along the proven path — published → content-changed → re-ingest-confirmed → human-approved → measured freshness/quality delta — with every link at its true strength (proven, pending, weak, or not_applicable). There is no single "improved" boolean; intent is never reported as outcome, and a freshness/quality delta is shown only when it was actually measurable.
A published fix not confirmed live within the configured re-ingest-confirm timeout (IMPROVEMENT_REINGEST_CONFIRM_TIMEOUT_HOURS, default 72 hours) is reported on its re-ingest link as weak with the label "stale — published but never confirmed live", distinguishing a stuck publish from a normal in-flight one (labelled "published, not yet confirmed live").
Query Parameters:
- limit (optional, default 50) — Maximum number of chains to return, newest first. Clamped to a server-side maximum.
Response:
{
"chains": [
{
"draft_id": "uuid",
"target_document_id": "uuid",
"proven_depth": 2,
"links": [
{ "key": "published", "state": "proven", "label": "published toward gap", "detail": null },
{ "key": "content_changed", "state": "pending", "label": "awaiting re-ingest", "detail": null },
{ "key": "reingest_confirmed", "state": "weak", "label": "stale — published but never confirmed live", "detail": null },
{ "key": "human_approved", "state": "proven", "label": "approved by human review", "detail": null },
{ "key": "delta", "state": "not_applicable", "label": "delta not measurable for this fix", "detail": null }
]
}
],
"aggregate": {
"published": 12,
"reingest_confirmed": 7,
"human_approved": 5,
"measured_improvement": 3
}
}
Auth: Admin role.
Publish Targets¶
Manage per-space publish target routing. Admin-only endpoints.
List Publish Targets¶
Response:
[
{
"id": "uuid",
"space": "PLATFORM",
"target_type": "github",
"config": {"repo": "acme/platform-docs", "token_env": "GITHUB_PUBLISH_TOKEN"},
"priority": 10,
"created_at": "2026-03-25T00:00:00Z",
"updated_at": "2026-03-25T00:00:00Z"
}
]
Create Publish Target¶
Request Body:
{
"space": "PLATFORM",
"target_type": "github",
"config": {
"repo": "acme/platform-docs",
"token_env": "GITHUB_PUBLISH_TOKEN",
"branch": "main",
"docs_path": "docs"
},
"priority": 10
}
space— Space key to route (omit ornullfor default/fallback target)target_type—confluence,github, orgitlabconfig— JSONB target configuration. Must usetoken_env(env var name) instead of raw secrets. Fields containingtoken,api_token, orsecretare rejected.priority— Higher priority targets are chosen first (default: 0)
Response: 201 Created with the created target.
Errors:
- 400 — Invalid target type or raw secrets in config
- 409 — A target for this space + type already exists
Update Publish Target¶
Partial update — only provided fields are changed.
Request Body:
Response: 200 OK with the updated target.
Delete Publish Target¶
Response: 204 No Content
Weekly Digest Preview¶
Returns the current weekly digest data (without sending it to Slack).
Response:
{
"period_start": "2025-02-13T00:00:00Z",
"period_end": "2025-02-20T00:00:00Z",
"total_queries": 152,
"unanswered_queries": 18,
"top_gaps": [
{
"id": "uuid",
"label": "Production Deployment Process",
"query_count": 47,
"severity": "critical",
"trend": "recurring"
}
],
"new_drafts": [...],
"stale_doc_count": 7,
"top_docs_by_queries": [
{
"title": "API Rate Limits",
"source_url": "https://...",
"space": "PLATFORM",
"retrieval_count": 94,
"author": "author@example.com"
}
]
}
top_docs_by_queries — the 10 documents most frequently retrieved during the period, with author attribution. Gives doc owners insight into which content is being searched most heavily.
Knowledge Health¶
Health Report¶
Full knowledge base health overview.
Response:
{
"total_documents": 342,
"overall_health_score": 67.3,
"freshness_distribution": {
"fresh": 120,
"review": 89,
"stale": 72,
"outdated": 41,
"archive": 20
},
"top_stale_cited_docs": [
{
"title": "Deploy Guide",
"freshness_score": 23.0,
"citations_last_7d": 47,
"contradiction_score": 45.0
}
],
"coverage_gaps": 15
}
Admin Endpoints¶
All admin endpoints require an admin-role API key.
List API Keys¶
Create API Key¶
Request Body:
role: viewer, editor, analyst, admin
viewer— ask questions, browse answers, give feedback, and access all intelligence dashboards (Documentation Analytics, Predictive Gaps, Autonomous Document Maintenance, Knowledge Stream)editor— everything viewer can + manage spaces and capturesanalyst— everything editor can + withdraw any capture, and the other endpoints marked Requires analyst role; editors cannot use theseadmin— full access including user management, RBAC config, and ingest triggers
allowed_spaces: hard-filters all queries and ingestion to the listed spaces. Empty array = no restriction.
Revoke API Key¶
Onboarding Mode¶
Returns an AI-curated reading list for a new team member.
Query Parameters:
- role — Job role or persona (e.g. platform-engineer, sre, backend-developer)
- days (optional, default: 7) — Onboarding period in days
Response:
{
"role": "platform-engineer",
"reading_list": [
{
"title": "Platform Onboarding Guide",
"source_url": "https://...",
"freshness_score": 85.0,
"reason": "Direct onboarding guide covering role-specific processes and expectations."
}
]
}
Admin — MCP Manifests¶
Admin endpoints for inspecting and operating the MCP tool platform — viewing merged tool catalogs, forcing discovery probes, and managing OAuth probe-user designations for dynamic manifests.
All endpoints require the admin role.
GET /api/v1/admin/mcp/manifests/{id}¶
Get full manifest detail including the merged tool catalog and discovery status.
Response (200 OK):
{
"id": "github",
"active_version": 5,
"display_name": "GitHub",
"auth": { "...": "..." },
"secrets": [ "..." ],
"tools": [
{
"name": "github.issue_read",
"description": "...",
"tool_source": "discovered",
"read_only": true,
"args_schema": { "...": "..." },
"output_size_cap_bytes": 16384,
"latency_budget_ms": 7000,
"upstream_name": "issue_read"
}
],
"discovery": {
"mode": "dynamic",
"refresh_seconds": 3600,
"status": "ok",
"last_attempt": "2026-05-15T20:42:11Z",
"last_success": "2026-05-15T20:42:11Z",
"last_error": null,
"next_scheduled": null,
"collisions": []
},
"probe_user": {
"user_id": "uuid",
"designated_at": "ISO-8601",
"designated_by": "uuid",
"last_probed_at": "ISO-8601|null"
}
}
Field semantics:
tools[].tool_source— one ofstatic|discovered|static_override. Tools are merged from both sources; collisions surface indiscovery.collisions.discovery.mode—static|dynamic|unknown(the last when the manifest isn't in the registry, e.g. after a discovery-disabled rollback).discovery.status—not_applicable|pending|ok|failed|requires_probe_user|degraded_collisions.discovery.last_success— populated forokanddegraded_collisions(degraded means the probe succeeded with name collisions; it is not a probe failure).discovery.collisions[]— names that appear in both static and discovered catalogs when neither side hasoverride_discovered: true.probe_user— omitted entirely when no designation exists.
For static manifests discovery.mode == "static" and all other discovery
fields are null. Every tool has tool_source: "static".
POST /api/v1/admin/mcp/manifests/{id}/discover¶
Force an immediate probe of a dynamic MCP manifest. Synchronously runs the probe and returns the new catalog, or surfaces the failure inline so the admin UI can render it.
Path params:
- {id} — the manifest_id.
Responses:
- 200 OK — { "outcome": "ok", "count": N, "tools": [...], "probed_at": "ISO-8601" }
- 200 OK — { "outcome": "failed", "error": { "kind": "...", "detail": "..." }, "probed_at": "ISO-8601" }
- 404 Not Found — manifest_id not in registry
- 409 Conflict — manifest is static, OR a probe is already in flight for this manifest
- 503 Service Unavailable — discovery worker not configured
- 504 Gateway Timeout — probe did not complete within 7s
- 500 Internal Server Error — worker dropped the reply channel (bug indicator)
error.kind is a stable snake_case token: timeout | auth | http |
parse | transport | catalog_too_large | not_dynamic | not_found |
requires_probe_user | already_in_flight. The UI can branch on this; detail
is human-readable.
Audit: writes mcp.manifest.discover to audit_log with {outcome, count}
on success or {outcome, error_kind, error_detail} on failure.
GET /api/v1/admin/mcp/manifests/{id}/probe-user¶
Read the current OAuth probe-user designation. Returns the user designated to provide OAuth credentials for periodic discovery probes on this manifest.
Responses:
- 200 OK —
{
"manifest_id": "github",
"user_id": "uuid",
"designated_at": "ISO-8601",
"designated_by": "uuid",
"last_probed_at": "ISO-8601|null"
}
404 Not Found — no probe user designated for this manifest
Unlike the other probe-user endpoints, this read succeeds even when MCP discovery is disabled, so admins can audit historical designations after a rollback.
PUT /api/v1/admin/mcp/manifests/{id}/probe-user¶
Designate a user as the OAuth probe credential source. Validates that the user
has a non-revoked, non-expired OAuth token for this manifest before recording
the designation. The discovery worker will use this user's token for periodic
tools/list probes.
Request: { "user_id": "uuid" }
Responses:
- 204 No Content — designation recorded
- 409 Conflict — user has no valid OAuth token for this manifest (the user
must connect first via the standard OAuth flow)
- 503 Service Unavailable — discovery worker not configured
Audit: writes mcp.manifest.probe_user.set with {user_id}.
DELETE /api/v1/admin/mcp/manifests/{id}/probe-user¶
Remove the OAuth probe-user designation. After unset, the manifest's discovery
status flips to requires_probe_user on the next probe tick — no probes will
run until a new user is designated.
Responses:
- 204 No Content — designation removed (or was already absent)
- 503 Service Unavailable — discovery worker not configured
Audit: writes mcp.manifest.probe_user.unset with {prior_user_id} (the
user being un-designated, captured pre-delete for audit completeness).
GET /api/v1/admin/principals¶
Search the principals table by case-insensitive prefix match on display name or external id. Powers the admin UI's principal typeahead (e.g. when scoping a tool to an SSO group or user).
Query params:
- q — prefix to match against display and external_id.
- limit — max results, clamped to 1..=100 (default 25).
Response (200 OK):
{
"principals": [
{
"id": 3,
"kind": "sso_group",
"source": "sso",
"external_id": "engineering",
"display": "Engineering"
}
]
}
GET /api/v1/admin/mcp/manifests/{id}/usage¶
Aggregate the MCP audit log for a manifest over a rolling window. Powers the admin UI Usage dashboard.
Query params:
- days — window size, clamped to 1..=90 (default 7).
Response (200 OK):
{
"series": [{ "day": "2026-05-19", "outcome": "ok", "count": 12 }],
"top_users": [{ "user_id": "uuid", "display": "Alice", "count": 9 }],
"top_failures": [{ "tool_name": "jira.search", "args": {}, "error_class": "timeout", "count": 3 }]
}
Field semantics:
- series[] — daily invocation counts grouped by outcome.
- top_users[] — top 10 attributable users by invocation count.
- top_failures[] — top 5 failing (tool_name, error_class) groups, each with
a redacted-args sample.
POST /api/v1/admin/mcp/manifests/{id}/disable¶
Reversibly disable a manifest. Sets all of the manifest's enablements to disabled, removing it from tool dispatch while preserving its scope configuration — so re-enabling is a single step. Idempotent.
Responses:
- 200 OK — manifest disabled (or was already disabled).
Audit: writes a manifest-disable entry to audit_log.
DELETE /api/v1/admin/mcp/manifests/{id}¶
Irreversibly uninstall a manifest in a single transaction. Clears the active-version pointer, then removes the manifest's OAuth tokens, probe users, secrets, enablements, and all installed versions. Per-user OAuth tokens are deleted — users must re-authorize on reinstall.
Responses:
- 200 OK — manifest uninstalled.
- 404 Not Found — manifest not installed.
Audit: writes a manifest-uninstall entry to audit_log.
Admin — MCP Registry & Install¶
Admin endpoints for browsing the signed remote MCP registry, fetching a
single manifest from it, and installing a manifest in a single transactional
call. All endpoints require the admin role.
The 3 registry endpoints (GET /registry, GET /registry/{id}/manifest,
POST /install-from-registry) require MCP_REGISTRY_PUBKEY to be set at
boot. When unset the server boots normally and these endpoints return
503 Service Unavailable; admins can still install manifests via the
existing paste/URL flow.
GET /api/v1/admin/mcp/registry¶
List the cached signed registry index.
The server fetches the index from MCP_REGISTRY_URL, verifies its Ed25519
signature against MCP_REGISTRY_PUBKEY, and caches it on disk at
MCP_REGISTRY_CACHE_PATH so subsequent calls survive transient network
outages (Tier 2 fallback).
Response (200 OK):
{
"schema_version": 1,
"generated_at": "2026-05-15T20:42:11Z",
"entries": [
{
"id": "github",
"version": "1.4.2",
"display_name": "GitHub",
"summary": "Read-only GitHub MCP — issues, PRs, code search.",
"manifest_url": "https://registry.docbrain-ai.com/v1/github/1.4.2.yaml",
"manifest_sha256": "abc123…",
"signed": true
}
]
}
Responses:
- 200 OK — index returned (from network or disk cache).
- 502 Bad Gateway — both network fetch and disk cache failed (cold start
with no connectivity).
- 503 Service Unavailable — MCP_REGISTRY_PUBKEY not configured.
GET /api/v1/admin/mcp/registry/{id}/manifest¶
Fetch and verify a single manifest by registry id. Used by the install wizard to preview the manifest before committing.
Path params:
- {id} — registry entry id (e.g. github).
Response (200 OK):
{
"yaml": "id: github\ndisplay_name: GitHub\n...",
"entry": {
"id": "github",
"version": "1.4.2",
"display_name": "GitHub",
"manifest_url": "https://registry.docbrain-ai.com/v1/github/1.4.2.yaml",
"manifest_sha256": "abc123…",
"signed": true
}
}
Responses:
- 200 OK — manifest YAML + index entry returned.
- 404 Not Found — id not present in the registry index.
- 502 Bad Gateway — manifest fetch failed or sha256 / signature
verification failed.
- 503 Service Unavailable — MCP_REGISTRY_PUBKEY not configured.
POST /api/v1/admin/mcp/install-from-registry¶
Atomic install of a registry manifest. Fetches and verifies the manifest,
validates its schema, then in a single transaction writes
mcp_installed_manifests, sets mcp_active_manifest_version, creates
mcp_manifest_enablements for the requested scope, and records an
audit_log row with action admin_mcp_install_from_registry.
Request:
Or with a group scope:
{
"id": "github",
"version": "1.4.2",
"allow_unsigned": false,
"scope": { "type": "groups", "group_ids": ["uuid1", "uuid2"] }
}
allow_unsigned— MUST befalse. Unsigned installs are not supported by this endpoint; use the existing paste/URL install endpoint instead (which renders its own per-manifest opt-in audit row).scope.type—everyoneorgroups. Whengroups,group_idsis required.
Response (200 OK):
Idempotent: re-installing the same (id, version) pair returns 200 with
already_installed: true and does not duplicate audit rows.
Responses:
- 200 OK — installed (or already present).
- 400 Bad Request — allow_unsigned: true, malformed scope, or schema
validation failure on the fetched manifest.
- 404 Not Found — id not present in the registry index.
- 502 Bad Gateway — manifest fetch / verification failed.
- 503 Service Unavailable — MCP_REGISTRY_PUBKEY not configured.
Audit: writes admin_mcp_install_from_registry with {id, version,
scope, enabled_count} on success.
GET /api/v1/admin/mcp/secrets/audit/{manifest_id}¶
Inspect the running pod's environment for the env-var keys declared in
the manifest's service_account.secret_refs and render the kubectl
command to patch any missing ones.
Path params:
- {manifest_id} — the active manifest to audit.
Response (200 OK):
{
"manifest_id": "github",
"required": ["GITHUB_TOKEN", "GITHUB_APP_ID"],
"missing": ["GITHUB_APP_ID"],
"secret_name": "docbrain-secrets",
"namespace": "docbrain",
"patch_command": "kubectl create secret generic docbrain-secrets --from-literal=GITHUB_APP_ID=<PASTE_VALUE> --dry-run=client -o yaml | kubectl apply -f -"
}
When DOCBRAIN_K8S_SECRET_NAME or DOCBRAIN_K8S_NAMESPACE are unset, the
rendered command contains the placeholder strings <set
DOCBRAIN_K8S_SECRET_NAME> / <set DOCBRAIN_K8S_NAMESPACE> so the admin
sees exactly what to configure.
Operability: env vars inject once at container start. After applying
the rendered patch the admin MUST restart the pod (e.g. kubectl rollout
restart deployment/docbrain-server) for the audit endpoint to reflect
the new values.
Responses:
- 200 OK — audit result returned.
- 404 Not Found — manifest_id unknown.
POST /api/v1/admin/mcp/secrets/oauth¶
Reserved for v2 — programmatic OAuth-client-secret install via the Kubernetes API.
Response (501 Not Implemented):
{
"error": "not_implemented",
"hint": "Use the kubectl command rendered by GET /api/v1/admin/mcp/secrets/audit/{manifest_id} until v2 wires the Kubernetes client.",
"kind": "v1_kubectl_only"
}
Admin — Ownership Accuracy Audit¶
POST /api/v1/admin/ownership/audit¶
Run an accuracy audit over the labeled ownership set. This evaluates how often the expertise scorer's confident attributions are wrong, and derives the confidence cutoff that meets a target error rate — the evidence operators use to decide whether to open the UI accuracy gate. Admin role required.
Request body:
-target_error — the maximum confidently-wrong rate to target when deriving the confidence cutoff (e.g. 0.0–1.0).
- split_frac — the calibration/test split fraction; clamped internally to [0.1, 0.9].
Response (200): an accuracy summary containing the confident and abstain totals, the measured confidently-wrong rate, the area under the risk-coverage curve, the full risk-coverage curve, and the calibrate-then-test report.
Knowledge Graph¶
GET /api/v1/graph/entity/{name}¶
Disambiguate an entity by name and return its subgraph (neighbors and edges).
Response:
{
"ranked": [
{
"entity": { "id": "uuid", "name": "payments-service", "entity_type": "service" },
"score": 0.95,
"reason": "direct_connections=2, degree=15"
}
],
"subgraph": {
"nodes": [
{ "id": "uuid", "name": "payments-service", "entity_type": "service" }
],
"edges": [
{ "from_entity_id": "uuid1", "to_entity_id": "uuid2", "relation_type": "DEPENDS_ON" }
]
}
}
GET /api/v1/graph/dependencies/{entity_id}¶
Multi-hop dependency traversal from a given entity.
Query params: depth (default 2, max 5), direction (downstream | upstream | both), relation_types (comma-separated, optional)
Response:
[
{
"entity": { "id": "uuid", "name": "auth-service", "entity_type": "service" },
"depth": 1,
"path": ["uuid1", "uuid2"]
}
]
GET /api/v1/graph/blast-radius/{entity_id}¶
Determine what is affected if an entity changes or goes down.
Query params: depth (default 3, max 5)
Response:
{
"entity": { "id": "uuid", "name": "payments-service", "entity_type": "service" },
"affected": [{ "entity": { "id": "uuid", "name": "checkout-service", "entity_type": "service" }, "depth": 1, "path": [] }],
"by_type": { "service": [{ "id": "uuid", "name": "checkout-service", "entity_type": "service" }] },
"by_depth": { "1": [{ "id": "uuid", "name": "checkout-service", "entity_type": "service" }] }
}
GET /api/v1/graph/path¶
Find the shortest path between two entities.
Query params: from (UUID, required), to (UUID, required), depth (max hops, default 5)
Response: Array of GraphEdge objects, or null if no path found within depth.
GET /api/v1/graph/experts/{topic}¶
Route to domain experts via the entity-to-team-to-person chain.
Response:
[
{
"person": { "id": "uuid", "name": "Alice Smith", "entity_type": "person" },
"team": { "id": "uuid", "name": "platform-team", "entity_type": "team" },
"confidence": 0.85,
"route": []
}
]
Documentation Analytics¶
GET /api/v1/analytics/velocity¶
Org-wide documentation analytics metrics over a configurable time window.
Query params: days (default 30)
Response:
{
"current_velocity": 2.5,
"velocity_trend": "accelerating",
"grade": "B",
"knowledge_half_life_days": 45,
"tribal_knowledge_pct": 0.15,
"documentation_roi": {
"queries_deflected": 340,
"estimated_hours_saved": 85
},
"weekly_snapshots": [
{ "week": "2026-03-09", "velocity": 2.3, "docs_created": 4, "gaps_resolved": 2 }
],
"per_team": [{ "team": "platform", "velocity": 3.2, "grade": "A" }]
}
GET /api/v1/analytics/velocity/teams¶
Per-team velocity breakdown.
Query params: days (default 30)
GET /api/v1/analytics/velocity/roi¶
Org-wide ROI summary: total queries deflected, hours saved, and cost saved in USD over the selected time window.
Query params: days (default 30)
Response:
Predictive Gap Detection¶
POST /api/v1/predictive/code-change¶
Detect documentation that may be stale after a code change.
Request:
Response: Array of PredictedGap objects with doc_id, title, reason, confidence, trigger.
GET /api/v1/predictive/cascade¶
Detect cascade staleness — documents that reference recently-updated documents and may now be inconsistent.
GET /api/v1/predictive/seasonal¶
Detect seasonal query patterns approaching their predicted peak for proactive refresh.
GET /api/v1/predictive/onboarding¶
Detect onboarding gaps — common questions from new hires that are poorly covered or missing from documentation.
Doc Maintenance¶
GET /api/v1/maintenance/fixes¶
List auto-detected fix proposals (contradictions, broken links, version bumps).
Query params: doc_id (optional), status (pending | approved | applied | rejected), limit (default 50)
POST /api/v1/maintenance/fixes/{id}/apply¶
Apply a fix proposal. Requires authentication.
Response: 200 OK
POST /api/v1/maintenance/fixes/{id}/reject¶
Reject a fix proposal. Requires authentication.
Response: 200 OK
GET /api/v1/maintenance/stats¶
Aggregate fix proposal statistics.
Response:
Knowledge Stream¶
GET /api/v1/stream/events¶
List recent stream events (incidents, decay alerts, expertise gaps, doc updates).
Query params: since (RFC3339), type (incident_warning | decay_alert | expertise_gap | doc_updated), limit (default 50)
GET /api/v1/stream/events/user/{user_id}¶
Personalized event stream filtered by the user's active context.
POST /api/v1/stream/context¶
Update user context (services and topics) for personalized stream delivery.
Request:
GET /api/v1/stream/stats¶
Event count statistics broken down by time window (24h, 7d, 30d) and event type.
Event Bus¶
GET /api/v1/events¶
Query the persistent event log. Requires admin role.
Query params:
| Param | Type | Default | Description |
|---|---|---|---|
type |
string | — | Filter by event type (e.g. gap.detected, document.ingested) |
since |
string | — | RFC3339 datetime or YYYY-MM-DD — only events after this time |
limit |
integer | 100 |
Max results (1–1000) |
offset |
integer | 0 |
Pagination offset |
Response:
{
"events": [
{
"id": "uuid",
"event_type": "gap.detected",
"payload": { "cluster_id": "uuid", "severity": "critical", "label": "...", "query_count": 15, "unique_users": 8 },
"emitted_at": "2026-03-21T14:30:00Z",
"processed_by": ["event_logger"]
}
],
"count": 1,
"limit": 100,
"offset": 0
}
Event types: document.ingested, document.updated, document.deleted, freshness.changed, fragment.captured, fragment.indexed, fragment.promoted, gap.detected, gap.assigned, gap.resolved, draft.generated, draft.review_requested, draft.published, draft.rejected, query.answered, feedback.received, sla.breached, maintenance.fix_proposed
GET /api/v1/events/stream¶
SSE stream of real-time events. Requires admin role. Max 10 concurrent connections.
Each SSE message includes:
- event: — the event type (e.g. gap.detected)
- id: — unique event UUID (for Last-Event-ID reconnection)
- data: — JSON payload with the full EventEnvelope (id, event, emitted_at)
Knowledge Fragments¶
POST /api/v1/fragments¶
Create a new knowledge fragment. Requires editor role.
A capture is served the moment it is written. Its label is computed when it is read: Checked against code (a premise holds and none is broken), Code it rests on changed (a premise is broken), Not checked against code (otherwise). A confidence sent in the request is ignored.
The server refuses, saving nothing:
| Status | kind |
When |
|---|---|---|
403 |
— | the key is not bound to a user: every capture needs a person behind it |
400 |
source_type_refused |
source_type is conversation_distill, ci_analyze or deploy: DocBrain writes those itself |
422 |
secret_in_capture (and other secret_* kinds) |
the text holds what looks like a secret; the reply names the kind and the field, never the value |
422 |
— | the text holds an instruction to the model |
429 |
capture_limit |
more than fragments.max_per_author_per_hour captures (default 60) from one author in an hour; Retry-After says when |
429 |
capture_paused |
three secret refusals in ten minutes pause capturing for ten minutes |
409 |
already_captured |
the same author sent the same text within 24 hours; id names the existing capture |
503 |
— | indexing failed; nothing was saved |
Errors share one shape: {"error": "...", "kind": "...", "id"?: "..."}. A declared path premise that a connected repository says does not exist is returned as a warnings entry; the capture is still saved.
Request body:
{
"fragment_type": "decision",
"summary": "Switched from Redis pub/sub to PG LISTEN/NOTIFY",
"content": "Redis cluster mode doesn't support pub/sub across shards...",
"source_type": "pr_merge",
"source_ref": "https://github.com/acme/platform/pull/1234",
"source_id": "github:acme/platform#1234",
"space": "PLATFORM",
"related_doc_ids": ["550e8400-e29b-41d4-a716-446655440000"],
"code_location": "src/events/publisher.rs:42",
"premises": [{ "premise_type": "path", "expression": "src/events/publisher.rs" }]
}
| Field | Type | Required | Description |
|---|---|---|---|
fragment_type |
string | yes | decision, fact, caveat, procedure, context |
summary |
string | yes | Short description |
content |
string | yes | Full content (max FRAGMENT_MAX_CONTENT_LENGTH) |
source_type |
string | yes | pr_merge, commit, ide_annotation, conversation_distill, deploy, incident, manual, ci_analyze |
source_ref |
string | no | URL or reference to the source |
source_id |
string | no | Dedup key (unique per source_type) |
space |
string | no | Space for routing/filtering |
related_doc_ids |
UUID[] | no | Related document IDs |
code_location |
string | no | File path and line (e.g. src/foo.rs:42) |
premises |
object[] | no | Conditions that must stay true for the fragment to hold, each {"premise_type": "path", "expression": "<repo-relative path>"}. Makes an anchored capture falsifiable. path is the only type checked in v1. |
Response: 201 Created
routed_action is deprecated: read status. It is kept for released clients and says only whether the capture is served (auto_index) or not (discard).
GET /api/v1/fragments¶
List fragments with optional filters.
| Param | Type | Default | Description |
|---|---|---|---|
status |
string | — | Filter by status: indexed, promoted, withdrawn (your own; analysts see every status), and for analysts the legacy pending, discarded, review_queued |
space |
string | — | Filter by space |
source_type |
string | — | Filter by one source type |
source |
string | — | Filter by source group: ide (IDE, commit, API, incident, PR merge), ci (CI and deploy summaries), conversations |
mine |
boolean | false |
Only the caller's own captures (needs a key bound to a user) |
closed |
boolean | false |
Only captures that closed an open question |
sort |
string | newest |
newest, or most_used (with mine=true only: ordering other people's captures by use would rank their authors) |
limit |
integer | 50 |
Max results (1–1000) |
offset |
integer | 0 |
Pagination offset |
GET /api/v1/fragments/:id¶
Get a single fragment by ID.
PATCH /api/v1/fragments/:id¶
Edit a capture. Only its author, on a capture DocBrain is serving; a summary DocBrain wrote cannot be edited, only withdrawn. Send summary, content and/or premises (the premise set is replaced whole). The new text is screened like a new capture (422, 429); 409 capture_changed when it changed while you edited.
DELETE /api/v1/fragments/:id¶
Delete a capture. Only its author, and only while no answer has used it: afterwards 409 {"error": "already used: withdraw it instead", "kind": "already_used"}.
POST /api/v1/fragments/:id/withdraw¶
Stop serving a capture at once. Its author, or an analyst or admin. Body {"reason": "..."} (required, at most 1000 characters, shown with the capture). The response says carried_by_draft when a composed draft still carries the text, and reason_redacted (the kind) when the reason held a secret and was stored without it. 409 when it is already withdrawn.
POST /api/v1/fragments/:id/purge¶
Erase a capture everywhere it is kept: its text, its premises and the answers that quoted it. Requires admin role. 204. Cannot be undone.
POST /api/v1/fragments/withdraw-by-author¶
Withdraw every live capture one author recorded, in one act. Requires admin role. Body {"author_id": "uuid", "reason": "..."}; the reason is required and shown with each capture. Returns {"withdrawn": ["uuid", ...]}. Content moderation, never a score.
GET /api/v1/fragments/:id/page¶
A capture's own page: the capture and its label, its premises with state and when last checked, its history, and the actions this caller may take. helped (people helped this month, open questions closed, answers marked wrong, and topic rows counting people) is returned to the capture's author only; who asked is never returned. 404 for a capture the caller cannot see.
GET /api/v1/captures/mine/impact¶
The caller's own numbers over the last 30 days: people their captures helped (distinct askers other than the author, with the 30 days before and 8 weekly points), open questions their captures closed, and their captures whose code changed (each with people_before_change). Optional source as above. Needs a key bound to a user.
GET /api/v1/captures/organisation¶
Knowledge at work across the organisation, never a person: the share of answers grounded in a capture (null for a key limited to some spaces, a null share when a period has no answers), questions closed by a capture, captures whose code changed, areas, the five most-used captures, unanswered questions with no capture (only those at least two people other than the reader asked), and captures whose code changed. No author appears anywhere in it.
GET /api/v1/fragments/stats¶
Fragment statistics — counts by status, source type, and space. Supports ?space= filter.
GET /api/v1/fragments/clusters¶
Discover semantic clusters of related fragments. Uses embedding similarity to group fragments by topic. Requires analyst role.
Returns clusters sorted by composability score (highest first). Clusters with composable: true meet the composition threshold (5+ fragments, diverse sources, 500+ words).
Response:
[
{
"topic": "Redis Cluster Mode Limitations",
"fragment_ids": ["uuid1", "uuid2", "uuid3", "uuid4", "uuid5"],
"source_diversity": 3,
"author_diversity": 4,
"total_content_words": 1250,
"composability_score": 0.85,
"sample_summaries": [
"Redis cluster mode doesn't support pub/sub across shards",
"MULTI/EXEC transactions limited to single slot"
],
"composable": true
}
]
| Status | Meaning |
|---|---|
| 200 | Clusters returned |
| 403 | Insufficient role (requires analyst) |
| 409 | Clustering is disabled via configuration |
POST /api/v1/fragments/clusters/compose¶
Compose a composable cluster into a documentation draft. Checks for existing doc coverage before composing — if a document already covers the topic (>70% similarity), composition is skipped. Requires admin role.
Request Body:
{
"cluster": {
"topic": "Redis Cluster Mode Limitations",
"fragment_ids": ["uuid1", "uuid2", "uuid3", "uuid4", "uuid5"],
"source_diversity": 3,
"author_diversity": 4,
"total_content_words": 1250,
"composability_score": 0.85,
"sample_summaries": ["..."],
"composable": true
}
}
Response:
| Status | Meaning |
|---|---|
| 200 | Draft created successfully |
| 400 | Cluster not composable or invalid request |
| 403 | Insufficient role (requires admin) |
| 409 | Clustering disabled, or existing doc already covers this topic |
Space Ownership & Governance¶
Explicit knowledge ownership — spaces get owners, maintainers, and contributors. Topics get stewards who are auto-assigned when matching gaps are detected. This is the accountability layer that ensures gaps get resolved and drafts get reviewed.
Governance is configured via API, not environment variables.
GET /api/v1/governance/spaces¶
List all spaces with ownership summary (owner/maintainer/contributor counts). Requires viewer role.
Response:
{
"spaces": [
{ "space": "PLATFORM", "owner_count": 1, "maintainer_count": 2, "contributor_count": 5 },
{ "space": "INFRA", "owner_count": 0, "maintainer_count": 0, "contributor_count": 0 }
]
}
GET /api/v1/governance/spaces/:space/owners¶
List owners, maintainers, and contributors for a specific space. Requires viewer role. The space is matched case-insensitively. Who each person is (user_id, user_email, user_display_name) is for editors and up: below editor a row keeps its role and notification flag, user_id is null and the email and name are left out; a reader always sees their own row in full.
Response:
{
"owners": [
{
"id": "uuid",
"space": "PLATFORM",
"user_id": "uuid",
"role": "owner",
"notifications_enabled": true,
"user_email": "alice@acme.com",
"user_display_name": "Alice"
}
]
}
POST /api/v1/governance/spaces/:space/owners¶
Add a user as owner, maintainer, or contributor of a space. Requires admin role.
Request body:
Valid roles: owner, maintainer, contributor.
Status codes: 201 Created, 409 if user already assigned, 400 if user not found.
PUT /api/v1/governance/spaces/:space/owner¶
Make a person the owner of a space, and optionally take the owner role from someone else, in one idempotent step. Requires admin role. Refused (403) for an API key limited to some spaces. The space is matched case-insensitively and stored upper-case.
Request body:
user_id must be a person who can sign in (an active account); otherwise 400. Afterwards that person holds the owner role on the space: a new row is added, or an existing maintainer or contributor row is promoted (its notification setting is kept; a new row has notifications on). If replaces names a different person, that person's row on the space is removed when it is an owner row; if they have no row, or are not an owner, nothing happens and there is no error. The whole change is one transaction, and repeating the same request changes nothing, so a retry after a lost response is always safe. Answers 200 with the space's rows after the change, in the shape of GET /owners.
DELETE /api/v1/governance/spaces/:space/owners/:user_id¶
Remove a user from a space's ownership. Requires admin role.
Status codes: 204 No Content, 404 if not found.
PATCH /api/v1/governance/spaces/:space/owners/:user_id¶
Update a space owner's role or notification preference. Requires admin role.
Request body:
At least one field must be provided.
GET /api/v1/governance/stewards¶
List all topic stewards with their regex patterns and the gap switch. Requires viewer role. Who a steward is (user_id, display_name, user_email, user_display_name) is for editors and up: below editor each steward keeps its pattern and flags, user_id and display_name are null and the other two are left out; a reader always sees their own steward rows in full. GET /stewards/:id and GET /my-stewardships follow the same rule.
Response:
{
"stewards": [
{
"id": "uuid",
"topic_pattern": "kubernetes|k8s|eks",
"display_name": "Kubernetes Infrastructure",
"user_id": "uuid",
"auto_assign_gaps": true,
"user_is_active": true,
"user_email": "carol@acme.com",
"user_display_name": "Carol"
}
]
}
POST /api/v1/governance/stewards¶
Create a topic steward. The topic_pattern is a regular expression, matched without regard to case against the labels of open gaps. Requires admin role.
Request body:
{
"topic_pattern": "kubernetes|k8s|eks",
"display_name": "Kubernetes Infrastructure",
"user_id": "uuid",
"auto_assign_gaps": true
}
auto_assign_gaps is required (400 without it): true lets routing give this steward gaps, false never does. Pattern validation: at most 500 characters, a valid regular expression, and not one that matches the empty string (use .+ to take every gap). The person must be able to sign in (active, not a stub or an unaccepted invite). An auto_assign_fragments field from an older client is accepted and ignored. Status codes: 201 Created, 400 a missing auto_assign_gaps, an invalid pattern, user not found, or "This person cannot sign in, so they cannot take gaps".
How a gap is routed. After every gap analysis (where autopilot runs), each open gap with no assignee that routing has not acted on goes to the first of:
- the steward who takes gaps, can sign in, and whose pattern matches the most characters of the gap's label (ties: the oldest steward). The gap is assigned to them and they get a
gap_assignednotification; - the owners of the first space its questions looked in (most pages first) that has owners who can sign in. Those with notifications on get a
gap_routednotification and nobody is assigned; if none of them would hear, the gap stays undecided; - the team that owns the source, and then the org: neither acts yet, so such a gap stays undecided and is considered again at the next analysis.
A gap a person has assigned or unassigned is never routed again, and a gap whose owners were told is not told twice. Routing grants topic visibility only as the visibility rules say: to the assignee, and to anyone with a role on the space routing told; it grants nothing else. user_is_active: false on a steward means its person can no longer sign in, so routing passes it over.
POST /api/v1/governance/stewards/preview¶
What a pattern would take at the next routing run. Requires admin role. Refused (403) for an API key limited to some spaces.
steward_id is given when editing that steward: its new pattern is tried in its own place (its age). The answer is { "wins": 1, "visible": 3, "undecided": 3 }: of the open gaps routing may still act on (undecided), those whose topic the caller may see (visible), how many the pattern would win once the stored stewards are counted (wins). Only topics the caller may see are matched, so a pattern cannot be used to probe a withheld topic. Status codes: 200, 400 with the reason the pattern would be refused on save, 403, 404 for an unknown steward_id.
GET /api/v1/governance/stewards/:id¶
Get a single topic steward by ID. Requires viewer role.
DELETE /api/v1/governance/stewards/:id¶
Remove a topic steward. Requires admin role. Returns 204 or 404.
PATCH /api/v1/governance/stewards/:id¶
Update a topic steward's pattern, display name, or auto_assign_gaps. Requires admin role.
Request body:
At least one field must be provided, and a pattern that matches the empty string is refused. New patterns are validated before saving.
GET /api/v1/governance/my-spaces¶
List spaces the current user owns or maintains. Requires an API key with an associated user_id.
GET /api/v1/governance/my-stewardships¶
List topics the current user stewards. Requires an API key with an associated user_id.
GET /api/v1/governance/coverage¶
Ownership coverage report across all spaces. Requires viewer role.
Response:
{
"total_spaces": 12,
"owned_spaces": 9,
"coverage_pct": 75.0,
"unowned_spaces": ["INFRA", "SECURITY", "ONBOARDING"]
}
Total spaces are derived from the documents table (distinct space values), not from governance tables — ensuring unowned spaces are visible.
GET /api/v1/governance/overview¶
What needs a decision across governance, and the figures the Govern Overview draws. Requires viewer role. Refused (403) for an API key limited to some spaces, because every section counts across spaces.
Each section is read with its own 5-second timeout. A section that cannot be read is null and named in errors; the endpoint answers 200 with the rest. Fields are shaped by role on the server: owner, steward and assignee names and per-gap spaces are for editors and up; reviews is present for editors and up; setup for admins only. A gap's topic is shown only when at least two people other than the reader asked, or the reader is its assignee or anyone with a role on the gap's space; who asked is never returned. A subject's label (a gap) and title (a draft) are therefore nullable: null means the reader may not see that topic, and a late draft's title follows the same rule through the gap it was written for.
{
"as_of": "2026-10-01T13:40:00Z",
"late": { "as_of": "…", "items": [ /* up to 8, most overdue first; subject.label / subject.title may be null */ ], "counts": [{ "kind": "not_picked_up", "count": 7 }],
"truncated": true, "ranges": [{ "kind": "not_picked_up", "shortest_late_by_secs": 950400, "longest_late_by_secs": 1382400 }],
"policy_set": true },
"routing": { "analysed": true, "open_gaps": 15, "waiting": 7, "visible": 7, "steward": 0, "space_owners": 1, "team": null, "nobody": 1, "told": 4, "left": 1,
"items": [{ "gap_id": "uuid", "topic": "…", "opened_at": "…", "route": "space_owners", "space": "ENG", "names": ["Dana Reyes"] }] },
"coverage": { "total_documents": 14882, "spaces": [{ "space": "ENG", "documents": 13571, "owners": 1, "with_role": 2 }], "more": { "spaces": 3, "documents": 120, "owned": 1, "unreachable": 2 } },
"reviews": { "workflow": { "name": "Sample review", "stages": [{ "name": "sme_review", "display_name": "SME Review" }] },
"workflows": 1, "review_term_hours": 72, "written_ages_secs": [1500000], "stages": [{ "name": "sme_review", "display_name": "SME Review", "waiting_ages_secs": [] }],
"last_30d": { "entered": [0], "published": 0, "rejected": 0 }, "failed": { "total": 8, "first": "…", "latest": "…" } },
"quality": { "scorable": 14882, "scored": 14882, "avg": 57.2, "measured_since": "…",
"by_space": [{ "space": "ENG", "scorable": 13571, "scored": 13571, "avg": 57.5, "hist": [ /* 30 bins of 2 points, 30–90 */ ], "under_40": 0 }],
"more": null, "weekly": [{ "space": "ENG", "week_start": "2026-09-28", "avg": 57.5, "docs": 13571, "rescored": false }] },
"attribution": { "gate_open": false, "sources": [{ "source": "jira", "connected": true }] },
"setup": { "sla_policy": true, "workflows": 1, "stewards": 0 },
"errors": []
}
as_of is the moment the late list was read; the other sections are read within a few seconds of it. If the late list could not be read, it is the server's clock.
coverage.spaces[].owners is the people with a role on the space who can sign in and have notifications on (the people gap routing will tell, once it runs); with_role is the people with any role on the space, in any state (so with_role is never below owners). with_role > 0 with owners == 0 means people hold a role but nobody can be reached; with_role == 0 means the space has no owner at all. Owner names are sent to editors and up only. When more spaces exist than are drawn, the rest fold into coverage.more: spaces, documents, owned (folded spaces with owners > 0) and unreachable (folded spaces with with_role > 0 and owners == 0: people hold a role but nobody can be told).
quality.weekly is the average over the documents you have today (docs is how many a point covers): a document deleted, archived or moved since then changes past weeks.
routing.waiting is every open gap with no assignee. visible is how many of those the reader may see the topic of, and every other figure is over those: steward + space_owners + nobody + told + left = visible. steward, space_owners and nobody are what the next routing run will do with the gaps it may still act on, decided as the sweep decides them (a space whose owners all have notifications off counts under nobody); told is the gaps whose space's owners were already told and no one has taken; left is those routing will not act on again. team is null (not connected). All of them are null where no gap analyzer runs, never 0. routing.items lists the open gaps nobody has picked up, oldest first (at most 100); each carries its route (steward, space_owners, nobody, told, left), with space, spaces, names for editors and up (never names on a told gap) and told_at on a told one. A gap whose topic the reader may not see has a null topic and no route, and is in no figure but waiting.
GET /api/v1/governance/dashboard was removed in this release; nothing in the console read it.
Governance SLAs¶
SLA policies define maximum acceptable times for gap acknowledgment, gap resolution, draft review, and document freshness. Policies can be set per-space or org-wide (default). A periodic background checker detects breaches and emits SlaBreached events. freshness_review_days is stored and returned with each policy, but no check reads it today: nothing is reported late for freshness.
GET /api/v1/governance/slas¶
List all SLA policies. Requires viewer role.
Response:
{
"policies": [
{
"id": "uuid",
"space": null,
"gap_acknowledgment_hours": 48,
"gap_resolution_days": 14,
"draft_review_hours": 72,
"freshness_review_days": 30,
"created_at": "2025-01-01T00:00:00Z",
"updated_at": "2025-01-01T00:00:00Z"
}
]
}
The space: null row is the org-wide default (always present). Space-specific overrides appear with their space name.
PUT /api/v1/governance/slas/default¶
Upsert the org-wide default SLA policy. Requires admin role.
Request body (all fields optional — omitted fields keep current values):
{
"gap_acknowledgment_hours": 48,
"gap_resolution_days": 14,
"draft_review_hours": 72,
"freshness_review_days": 30
}
Validation: Hours must be 1–8760, days must be 1–365.
PUT /api/v1/governance/slas/:space¶
Upsert a space-specific SLA policy override. Requires admin role. Same request body as above.
DELETE /api/v1/governance/slas/:space¶
Delete a space-specific override (space falls back to org default). Requires admin role. Returns 204 No Content on success, 404 if no override exists for the space.
GET /api/v1/governance/breaches¶
What is late now, most overdue first. Requires viewer role. Refused (403) for an API key limited to some spaces. Returns the 100 most overdue subjects, every kind's uncapped count, and the instant they were judged at. Below editor, an assignee is sent empty ({}) and a row's space is null.
A gap's label and a draft's title are null when the reader may not see the topic (same rule as the overview: two other askers, or you are its assignee or have a role on its space; a draft follows its source gap).
Response:
{
"as_of": "2026-10-01T13:40:00Z",
"items": [
{
"kind": "not_picked_up",
"subject": { "type": "gap", "id": "uuid", "label": null },
"space": "ENGINEERING",
"assignee": null,
"draft": null,
"policy_hours": 48,
"clock_start": "2026-09-20T09:00:00Z",
"due_at": "2026-09-22T09:00:00Z",
"late_by_secs": 794400
},
{
"kind": "not_reviewed",
"subject": { "type": "draft", "id": "uuid", "title": "Rotating the widget service" },
"space": "ENGINEERING",
"assignee": null,
"draft": null,
"policy_hours": 72,
"clock_start": "2026-09-25T09:00:00Z",
"due_at": "2026-09-28T09:00:00Z",
"late_by_secs": 276000
}
],
"counts": [{ "kind": "not_picked_up", "count": 7 }, { "kind": "no_draft_yet", "count": 0 }, { "kind": "draft_not_submitted", "count": 0 }, { "kind": "not_reviewed", "count": 3 }],
"truncated": false
}
Review Workflows¶
Configurable multi-stage review pipelines for autopilot drafts. Each space can have a workflow that defines approval stages (e.g., SME Review → Writer Review → Publish Approval). Reviewers approve, request changes, or reject drafts at each stage.
GET /api/v1/governance/workflows¶
List all review workflows. Requires viewer role.
Response:
{
"workflows": [
{
"id": "uuid",
"space": "ENGINEERING",
"name": "Standard Review",
"stages": [
{ "name": "sme_review", "required_role": "maintainer", "approvals_needed": 1 },
{ "name": "writer_review", "required_role": "contributor", "approvals_needed": 1 }
],
"is_default": false,
"created_at": "2026-03-22T10:00:00Z",
"updated_at": "2026-03-22T10:00:00Z"
}
]
}
POST /api/v1/governance/workflows¶
Create a review workflow for a space. Each space can have at most one workflow. Requires admin role.
Request body:
{
"space": "ENGINEERING",
"name": "Standard Review",
"stages": [
{ "name": "sme_review", "required_role": "maintainer", "approvals_needed": 1 },
{ "name": "writer_review", "required_role": "contributor", "approvals_needed": 1 }
],
"is_default": false
}
Validation rules:
- Maximum 10 stages per workflow
- Stage names must be unique within a workflow
- required_role must be one of: owner, maintainer, contributor
- approvals_needed must be between 1 and 20
PATCH /api/v1/governance/workflows/:id¶
Update a workflow's name, stages, or default flag. Requires admin role.
DELETE /api/v1/governance/workflows/:id¶
Delete a workflow. Drafts assigned to this workflow retain their current stage but lose the workflow reference (ON DELETE SET NULL). Requires admin role.
POST /api/v1/drafts/:id/review¶
Submit a review action (approve, request changes, or reject) for a draft at its current stage. Requires editor role. The reviewer must also hold the stage's required_role in the draft's space governance.
Request body:
Valid actions: approve, request_changes, reject.
- approve: Counts toward the stage's
approvals_neededthreshold. When the threshold is met, the draft advances to the next stage (or becomesreviewedif it was the final stage). - request_changes: Recorded but does not block approvals. The draft stays at the current stage.
- reject: Immediately sets the draft status to
rejected.
Each reviewer can submit one action per stage. Submitting again updates the existing action (upsert).
Response: 200 OK with {"ok": true, "advanced": true} if the draft advanced to the next stage.
POST /api/v1/drafts/:id/skip-review¶
Admin bypass: skip all remaining review stages and move the draft directly to reviewed status. Requires admin role.
Use this when a draft doesn't need gatekeeping — e.g., trusted content sources, low-risk updates, or time-sensitive documentation.
Response: 200 OK with {"ok": true, "status": "reviewed"}
Returns 404 if the draft is not found or not in a reviewable state (must be status = 'draft').
GET /api/v1/drafts/:id/reviews¶
List all review actions for a draft. Requires viewer role.
GET /api/v1/drafts/:id/stage¶
Get the current review stage progress for a draft, including who has reviewed and how many approvals remain.
Response:
{
"draft_id": "uuid",
"current_stage": "sme_review",
"workflow_id": "uuid",
"progress": {
"stage_name": "sme_review",
"approvals_needed": 2,
"approvals_received": 1,
"actions": [
{
"actor_id": "uuid",
"action": "approve",
"note": "LGTM",
"created_at": "2026-03-22T11:00:00Z"
}
]
}
}
POST /api/v1/drafts/:id/comments¶
Add a review comment to a draft. Requires editor role.
Request body:
Comment body is limited to 10,000 characters. parent_id enables threaded replies.
GET /api/v1/drafts/:id/comments¶
List all comments on a draft, ordered by creation time. Requires viewer role.
PATCH /api/v1/comments/:id¶
Update a comment's body. Only the comment author or an admin can edit. Requires editor role.
POST /api/v1/comments/:id/resolve¶
Mark a comment as resolved. Requires editor role.
GET /api/v1/reviews/my-queue¶
List drafts awaiting the current user's review, based on their space governance roles. Returns drafts where the user holds the required role for the current stage.
Response:
{
"items": [
{
"draft_id": "uuid",
"title": "Getting Started with Kubernetes",
"space": "ENGINEERING",
"current_stage": "sme_review",
"workflow_name": "Standard Review",
"created_at": "2026-03-22T09:00:00Z"
}
]
}
Content Quality Scoring¶
Deterministic structural quality scores for documents and fragments. Each item receives a composite score (0-100) built from 7 sub-scores, with content-type-aware templates defining completeness expectations.
List Quality Scores¶
Paginated list of quality scores with optional filters. Requires viewer role.
Query Parameters:
| Parameter | Type | Default | Description |
|---|---|---|---|
space |
string | — | Filter by space |
min_score |
float | — | Minimum composite score (0-100) |
max_score |
float | — | Maximum composite score (0-100) |
content_type |
string | — | Filter by content type |
limit |
integer | 50 | Max results (capped at 200) |
offset |
integer | 0 | Pagination offset |
Response:
{
"scores": [
{
"id": "uuid",
"document_id": "uuid",
"fragment_id": null,
"heading_structure": 15.0,
"section_completeness": 20.0,
"code_presence": 10.0,
"link_density": 7.0,
"content_length": 8.0,
"readability": 12.0,
"metadata_quality": 10.0,
"composite_score": 82.0,
"scored_at": "2025-01-15T10:30:00Z"
}
]
}
Get Document Score¶
Quality score for a specific document. Requires viewer role.
Response:
{
"score": {
"id": "uuid",
"document_id": "uuid",
"heading_structure": 15.0,
"section_completeness": 20.0,
"code_presence": 10.0,
"link_density": 7.0,
"content_length": 8.0,
"readability": 12.0,
"metadata_quality": 10.0,
"composite_score": 82.0,
"scored_at": "2025-01-15T10:30:00Z"
},
"status": "high"
}
Status values: high (80+), acceptable (60+), needs_improvement (40+), poor (<40).
Trigger Rescore¶
Starts a background rescore of every scorable document. Returns immediately; progress is in the server logs, and /api/v1/quality/report shows the result. A background pass also scores any document without a current score every 15 minutes, so a rescore is only needed to force a re-read. Requires admin role.
Response:
{
"status": "accepted",
"documents_to_score": 1234,
"message": "Rescoring started in background — progress in server logs; check /api/v1/quality/report when complete."
}
Quality Report¶
Aggregate structure-score report over the scorable documents (live, chunked, with at least one chunk). scored_count of scorable_count documents have a current structural score; overall_avg averages structural_total over those and is null when scored_count is 0 ("not measured" is never a number). by_space and worst_docs (the 20 lowest, ordered by structural_total then id) cover the same scored documents. Requires analyst role.
Response:
{
"overall_avg": 72.5,
"scored_count": 1234,
"scorable_count": 1500,
"by_space": [
{ "space": "ENGINEERING", "avg_score": 78.3, "doc_count": 450 }
],
"worst_docs": [
{
"document_id": "uuid",
"title": "Legacy Migration Guide",
"content_type": "howto",
"structural_total": 23.5,
"heading_score": 2.0,
"completeness_score": 5.0,
"code_block_score": 0.0,
"link_score": 0.0,
"length_score": 6.0,
"readability_score": 7.5,
"metadata_score": 3.0
}
]
}
List Content Type Templates¶
Returns the built-in content type templates that define section completeness expectations. Requires viewer role.
Response:
{
"templates": [
{
"content_type": "runbook",
"required_sections": ["overview", "prerequisites", "steps", "rollback", "escalation"],
"optional_sections": ["monitoring", "troubleshooting"],
"min_word_count": 200,
"max_word_count": 5000,
"expect_code_blocks": true
},
{
"content_type": "guide",
"required_sections": ["introduction", "prerequisites", "steps"],
"optional_sections": ["examples", "faq", "next steps"],
"min_word_count": 300,
"max_word_count": 10000,
"expect_code_blocks": true
}
]
}
Available content types: runbook, guide, troubleshooting, faq, reference.
Sub-Score Breakdown¶
| Sub-Score | Range | What It Measures |
|---|---|---|
heading_structure |
0-20 | Presence of headings, proper hierarchy (H1→H2→H3), no skipped levels |
section_completeness |
0-25 | Required sections present per content type template |
code_presence |
0-10 | Code blocks present when expected by content type |
link_density |
0-10 | Internal/external links for cross-referencing |
content_length |
0-10 | Word count within template-defined min/max range |
readability |
0-15 | Sentence length variation, no wall-of-text paragraphs, manageable sentence lengths |
metadata_quality |
0-10 | Author, source URL, and space metadata present |
Semantic Quality (LLM-Assessed)¶
When semantic quality scoring is enabled, documents are also assessed by an LLM on four dimensions:
| Dimension | Range | Description |
|---|---|---|
accuracy |
0-25 | Claims grounded in cited sources |
completeness |
0-25 | Covers all aspects a reader needs |
clarity |
0-25 | Understandable without external help |
actionability |
0-25 | Provides concrete steps, commands, and examples |
The semantic score (0-100) is stored in the semantic_score field with per-dimension details in semantic_details. The composite_score becomes a blend of structural (0.4/0.7) and semantic (0.3/0.7) scores, but only when both describe the same content version; otherwise it is the structural score alone.
Semantic scoring runs as a background sweep (configurable interval, default 24h) and only evaluates documents with structural_total >= 40 to avoid wasting LLM calls on obviously poor content. Newly generated drafts are scored immediately after creation.
Style Rules Engine¶
Configurable linting rules for documentation consistency. Rules are scoped globally or per-space, with space-specific rules overriding global rules of the same type and name.
List Style Rules¶
Requires viewer role.
Query Parameters:
| Parameter | Type | Default | Description |
|---|---|---|---|
space |
string | — | Filter by space |
rule_type |
string | — | Filter by type: terminology, formatting, structure, custom_pattern |
include_inactive |
boolean | false |
Include inactive rules (admin only) |
Response:
{
"rules": [
{
"id": "uuid",
"space": null,
"rule_type": "terminology",
"name": "avoid-simple",
"description": "Avoid the word 'simple' — it dismisses reader difficulty",
"config": { "term": "simple", "suggestion": "straightforward" },
"severity": "warning",
"is_active": true,
"created_at": "2026-03-22T00:00:00Z",
"updated_at": "2026-03-22T00:00:00Z"
}
]
}
Create Style Rule¶
Requires admin role.
Request Body:
{
"space": null,
"rule_type": "terminology",
"name": "avoid-simple",
"description": "Avoid the word 'simple'",
"config": { "term": "simple", "suggestion": "straightforward" },
"severity": "warning"
}
| Field | Type | Required | Description |
|---|---|---|---|
space |
string | no | Space scope (null = global) |
rule_type |
string | yes | terminology, formatting, structure, custom_pattern |
name |
string | yes | Unique name (1-200 chars) |
description |
string | no | Human-readable description (max 2000 chars) |
config |
object | yes | Rule-type-specific configuration (see below) |
severity |
string | no | error, warning, info (default: warning) |
Config by rule type:
| Rule Type | Config Schema |
|---|---|
terminology |
{ "term": "string", "suggestion": "string" } |
formatting |
{ "max_heading_depth": number } or { "max_sentence_length": number } |
structure |
{ "require_intro": true } |
custom_pattern |
{ "pattern": "regex", "message": "string" } |
Response: 201 Created
Status codes: 400 invalid input, 409 duplicate (space + type + name), 422 rule limit reached.
Update Style Rule¶
Requires admin role. Only provided fields are updated.
Request Body:
{
"description": "Updated description",
"config": { "term": "simple", "suggestion": "clear" },
"severity": "error",
"is_active": false
}
Delete Style Rule¶
Requires admin role. Returns 204 No Content or 404.
Import Rules from YAML¶
Requires admin role. Upserts rules from a YAML string (max 100 rules per import). Existing rules with the same (space, type, name) are updated.
Request Body:
{
"yaml": "- rule_type: terminology\n name: avoid-simple\n config:\n term: simple\n suggestion: straightforward\n severity: warning\n"
}
Response:
Export Rules to YAML¶
Requires admin role. Returns all rules as a YAML document (Content-Type: application/x-yaml).
Lint Content¶
Requires analyst role. Runs all active rules against the provided content.
Request Body:
| Field | Type | Required | Description |
|---|---|---|---|
content |
string | yes | Text to lint (max 500KB) |
space |
string | no | Space for rule scoping (global + space rules apply) |
Response:
{
"violations": [
{
"rule_name": "avoid-simple",
"rule_type": "terminology",
"severity": "warning",
"message": "Avoid 'simple' — consider 'straightforward' instead",
"line": 1,
"column": 11,
"span": "simple"
}
],
"style_score": 95.0,
"summary": {
"errors": 0,
"warnings": 1,
"infos": 0,
"total": 1
},
"truncated": false
}
Style score formula: max(0, 100 - (errors × 15 + warnings × 5 + infos × 1)), clamped to [0, 100].
Limits¶
| Limit | Value |
|---|---|
| Max rules per space | 200 |
| Max total rules | 1000 |
| Max import batch | 100 |
| Max lint content | 500 KB |
| Max violations per lint | 100 |
| Max regex pattern length | 500 chars |
Webhooks¶
Outbound webhook subscriptions for pushing DocBrain events to external systems. All endpoints require admin role.
DocBrain signs every delivery with HMAC-SHA256 (X-DocBrain-Signature header), retries failed deliveries with exponential backoff, and automatically disables subscriptions after repeated failures (circuit breaker).
List Webhook Subscriptions¶
Response:
[
{
"id": "uuid",
"name": "Slack Pipeline",
"url": "https://hooks.example.com/docbrain",
"events": ["document.ingested", "gap.detected"],
"headers": { "X-Custom": "value" },
"is_active": true,
"created_by": "uuid",
"failure_count": 0,
"last_failure_at": null,
"last_success_at": "2026-03-22T10:00:00Z",
"disabled_reason": null,
"created_at": "2026-03-20T08:00:00Z",
"updated_at": "2026-03-22T10:00:00Z"
}
]
Note: The
secretfield is never returned in API responses.
Create Webhook Subscription¶
Request Body:
{
"name": "Slack Pipeline",
"url": "https://hooks.example.com/docbrain",
"secret": "your-secret-at-least-16-chars",
"events": ["document.ingested", "gap.detected"],
"headers": { "X-Custom": "value" }
}
| Field | Type | Required | Description |
|---|---|---|---|
name |
string | yes | Human-readable subscription name |
url |
string | yes | HTTPS endpoint to deliver events to. Must not be a private/internal IP unless ALLOW_INTERNAL_WEBHOOKS=true. |
secret |
string | yes | HMAC signing secret (minimum 16 characters) |
events |
string[] | yes | Event types to subscribe to (see list below) |
headers |
object | no | Extra HTTP headers to include in deliveries |
Response: 201 Created with the WebhookSubscription object.
Validation:
- secret must be at least 16 characters
- events must contain only valid event types
- url must not resolve to a private/internal IP address (unless ALLOW_INTERNAL_WEBHOOKS=true)
Update Webhook Subscription¶
Request Body: (all fields optional)
{
"name": "Updated Name",
"url": "https://new-endpoint.example.com/hook",
"secret": "new-secret-at-least-16-chars",
"events": ["document.ingested"],
"headers": { "X-Custom": "new-value" },
"is_active": true
}
Response: 200 OK with the updated WebhookSubscription object.
Delete Webhook Subscription¶
Response:
Send Test Event¶
Sends a webhook.test event to the subscription's URL. Useful for verifying connectivity and signature validation.
Response:
List Delivery Log¶
Returns recent delivery attempts for a subscription.
Query Parameters:
| Param | Type | Default | Description |
|---|---|---|---|
limit |
integer | 50 |
Max results |
Response: Array of WebhookDelivery objects with delivery ID, event type, HTTP status, response body, attempt number, and timestamps.
Reset Circuit Breaker¶
Resets the failure counter and re-enables a subscription that was auto-disabled by the circuit breaker.
Response:
Event Types¶
All event types from the internal event bus are available for webhook subscriptions:
| Event Type | Description |
|---|---|
document.ingested |
New document indexed |
document.updated |
Existing document re-indexed |
document.deleted |
Document removed |
freshness.changed |
Document freshness status changed |
fragment.captured |
Knowledge fragment captured |
fragment.indexed |
A capture is served (indexed into search) |
fragment.promoted |
A capture was composed into a draft |
gap.detected |
New gap cluster detected |
gap.assigned |
Gap assigned to a user |
gap.resolved |
Gap marked as resolved |
draft.generated |
AI draft generated for a gap |
draft.review_requested |
Draft sent for review |
draft.published |
Draft published to target system |
draft.rejected |
Draft rejected during review |
query.answered |
User query answered |
feedback.received |
User feedback submitted |
sla.breached |
Documentation SLA breached |
maintenance.fix_proposed |
Automated fix proposal generated |
Delivery Headers¶
Every webhook delivery includes these headers:
| Header | Description |
|---|---|
Content-Type |
application/json |
X-DocBrain-Signature |
HMAC-SHA256 signature: sha256=<hex-digest>. Compute HMAC-SHA256(secret, raw_body) and compare to verify authenticity. |
X-DocBrain-Event |
Event type (e.g. document.ingested) |
X-DocBrain-Delivery |
Unique delivery UUID for idempotency |
Retry Policy¶
Failed deliveries (non-2xx response or timeout) are retried with exponential backoff:
| Attempt | Delay |
|---|---|
| 1 | Immediate |
| 2 | 60 seconds |
| 3 | 300 seconds (5 minutes) |
| 4 | 3600 seconds (1 hour) |
After all retry attempts are exhausted, the failure counter is incremented. When the failure counter reaches WEBHOOK_CIRCUIT_BREAKER_THRESHOLD (default: 10) consecutive failures, the subscription is automatically disabled with disabled_reason: "circuit_breaker". Use POST /api/v1/webhooks/:id/reset to re-enable.
External Connectors¶
External connectors are stateless HTTP servers that implement a simple REST contract. DocBrain calls them on a cron schedule to ingest documents. All management endpoints require admin role. Status endpoint requires viewer+ role.
List Connectors¶
Response:
[
{
"id": "uuid",
"name": "internal-wiki",
"display_name": "Internal Wiki",
"base_url": "https://wiki-connector.example.com",
"source_type": "wiki",
"schedule_cron": "0 */6 * * *",
"space": "engineering",
"is_active": true,
"last_sync_at": "2026-03-24T06:00:00Z",
"last_sync_docs": 42,
"last_error": null,
"consecutive_failures": 0,
"created_at": "2026-03-20T08:00:00Z",
"updated_at": "2026-03-24T06:00:00Z"
}
]
Note: The
auth_headerfield is never returned in API responses.
Create Connector¶
Request Body:
{
"name": "internal-wiki",
"display_name": "Internal Wiki",
"base_url": "https://wiki-connector.example.com",
"auth_header": "Bearer your-token",
"source_type": "wiki",
"schedule_cron": "0 */6 * * *",
"space": "engineering"
}
| Field | Type | Required | Description |
|---|---|---|---|
name |
string | yes | Unique machine name (1-100 chars) |
display_name |
string | yes | Human-readable name (1-100 chars) |
base_url |
string | yes | Connector HTTP endpoint (1-2048 chars, must start with http:// or https://) |
auth_header |
string | no | Authorization header value (e.g. Bearer token) |
source_type |
string | yes | Document source type (1-20 chars, must be unique across connectors) |
schedule_cron |
string | no | Standard 5-field cron expression (default: 0 */6 * * * = every 6 hours) |
space |
string | no | Target space for ingested documents |
Response: 201 Created with the connector object.
Validation:
- base_url must not resolve to a private/internal IP address (unless CONNECTOR_ALLOW_INTERNAL=true)
- schedule_cron must be a valid cron expression
- source_type must be unique — returns 409 Conflict if already registered
Update Connector¶
Request Body: (all fields optional)
{
"display_name": "Updated Wiki",
"base_url": "https://new-url.example.com",
"auth_header": "Bearer new-token",
"source_type": "wiki",
"schedule_cron": "0 */12 * * *",
"space": "docs",
"is_active": true
}
Response: 200 OK with the updated connector object. Returns 404 if not found.
Delete Connector¶
Response: 200 OK with {"deleted": true}. Returns 404 if not found.
Trigger Manual Sync¶
Triggers an immediate sync for the connector, bypassing the cron schedule and circuit breaker. Returns an error if a sync is already in progress for this connector.
Response:
| Status | Meaning |
|---|---|
| 200 | Sync completed successfully |
| 404 | Connector not found |
| 409 | Sync already in progress |
Connector Status¶
Returns sync health information for a connector. Requires viewer+ role.
Response:
{
"id": "uuid",
"name": "internal-wiki",
"is_active": true,
"last_sync_at": "2026-03-24T06:00:00Z",
"last_sync_docs": 42,
"last_error": null,
"consecutive_failures": 0
}
Test Connector Health¶
Runs a health check against the connector's /health endpoint without triggering a sync.
Response:
If the health check fails:
Connector Protocol¶
External connectors must implement three endpoints:
| Endpoint | Method | Description |
|---|---|---|
/health |
GET | Returns {"status": "ok"} and optionally {"version": "..."} |
/documents/list |
POST | Lists available documents (paginated, supports incremental sync via since) |
/documents/fetch |
POST | Returns full document content for a list of source IDs |
List Request:
List Response:
{
"documents": [
{ "source_id": "doc-123", "title": "Getting Started", "updated_at": "2026-03-24T10:00:00Z" }
],
"has_more": false,
"total": 1
}
Fetch Request:
Fetch Response:
{
"documents": [
{
"source_id": "doc-123",
"title": "Getting Started",
"content": "# Getting Started\n\nWelcome to...",
"content_type": "markdown",
"url": "https://wiki.example.com/getting-started",
"author": "Jane Doe",
"updated_at": "2026-03-24T10:00:00Z",
"metadata": {},
"references": []
}
]
}
CI/CD Pipeline Capture¶
Automated knowledge extraction from merged PRs and deployments. Requires editor role.
POST /api/v1/ci/analyze¶
Analyze a merged PR and extract knowledge fragments.
Request body:
{
"pr_number": 1234,
"repo": "acme/platform",
"pr_title": "Switch event delivery to PG LISTEN/NOTIFY",
"pr_body": "Replaced Redis pub/sub with PostgreSQL...",
"diff_stat": "+120 -45",
"changed_files": "src/events/publisher.rs,src/events/subscriber.rs",
"labels": "architecture,breaking-change",
"author": "alice@acme.com"
}
| Field | Type | Required | Description |
|---|---|---|---|
pr_number |
integer | yes | Pull request number |
repo |
string | yes | Repository in owner/name format |
pr_title |
string | yes | PR title (max 500 chars) |
pr_body |
string | no | PR description (max 50,000 chars) |
diff_stat |
string | no | Git diff stats |
changed_files |
string | no | Comma-separated list of changed files |
labels |
string | no | Comma-separated PR labels |
author |
string | no | PR author email or username |
Response:
{
"fragments_created": 2,
"fragments": [
{
"id": "550e8400-e29b-41d4-a716-446655440000",
"fragment_type": "decision",
"summary": "Switched event delivery from Redis to PG LISTEN/NOTIFY",
"confidence": 0.85,
"routed_action": "auto_index"
}
],
"already_analyzed": false
}
| Status | Description |
|---|---|
| 200 | Analysis complete (check fragments_created and already_analyzed) |
| 400 | Validation error (missing required field, field too long) |
| 403 | Insufficient role (requires editor+) |
| 503 | CI analysis is disabled (CI_ANALYZE_ENABLED=false) |
The endpoint is idempotent: re-analyzing the same PR returns already_analyzed: true with no new fragments.
POST /api/v1/ci/deploy-capture¶
Capture deployment context as a knowledge fragment.
Request body:
{
"service": "payment-gateway",
"version": "2.4.1",
"environment": "production",
"changelog": "abc1234 Fixed retry logic\ndef5678 Updated timeout config",
"config_diff": "timeout: 30 -> 60"
}
| Field | Type | Required | Description |
|---|---|---|---|
service |
string | yes | Service name (max 256 chars) |
version |
string | yes | Version being deployed (max 128 chars) |
environment |
string | yes | Target environment (max 128 chars) |
changelog |
string | no | Git changelog (max 50,000 chars) |
config_diff |
string | no | Configuration changes (max 50,000 chars) |
Response:
{
"fragment_id": "550e8400-e29b-41d4-a716-446655440000",
"summary": "Deployed payment-gateway v2.4.1 to production with retry logic fix"
}
| Status | Description |
|---|---|
| 200 | Capture complete |
| 400 | Validation error |
| 403 | Insufficient role |
| 503 | CI analysis is disabled |
GitHub Action Setup¶
Add this workflow to your repository to automatically capture knowledge from merged PRs:
name: DocBrain Knowledge Capture
on:
pull_request:
types: [closed]
jobs:
capture:
if: github.event.pull_request.merged == true
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 2
- name: Capture knowledge from PR
env:
DOCBRAIN_API_URL: ${{ secrets.DOCBRAIN_API_URL }}
DOCBRAIN_API_KEY: ${{ secrets.DOCBRAIN_API_KEY }}
run: |
curl -s -X POST "${DOCBRAIN_API_URL}/api/v1/ci/analyze" \
-H "Authorization: Bearer ${DOCBRAIN_API_KEY}" \
-H "Content-Type: application/json" \
-d "$(jq -n \
--argjson pr_number ${{ github.event.pull_request.number }} \
--arg repo '${{ github.repository }}' \
--arg pr_title '${{ github.event.pull_request.title }}' \
'{pr_number: $pr_number, repo: $repo, pr_title: $pr_title}')"
Required repository secrets:
- DOCBRAIN_API_URL — Your DocBrain server URL
- DOCBRAIN_API_KEY — API key with editor+ role