Run docmgr as a local HTTP server with a JSON search API (v1), cursor pagination, and explicit index refresh.
docmgr can run as a local HTTP server exposing a versioned JSON API for searching documentation using the same query engine as the CLI.
This is intended for:
See also: docmgr-web-ui.md (Slug: web-ui) for running the bundled Search Web UI.
Security note: the server is local-first. Bind to 127.0.0.1 by default and don’t expose it publicly unless you add authentication and threat-model it.
Build and run the server:
go build -tags "sqlite_fts5,embed" -o /tmp/docmgr ./cmd/docmgr
/tmp/docmgr api serve --addr 127.0.0.1:8787 --root ttmp
Notes:
sqlite_fts5 enables full-text search.embed bundles the web UI assets into the binary (optional for API-only usage).Check health:
curl -s http://127.0.0.1:8787/api/v1/healthz
Refresh the index (explicit):
curl -s -X POST http://127.0.0.1:8787/api/v1/index/refresh
Search:
curl -s "http://127.0.0.1:8787/api/v1/search/docs?query=websocket&orderBy=rank&pageSize=50"
The server builds an in-memory SQLite index on startup and reuses it for requests.
POST /api/v1/index/refresh rebuilds the index from disk and swaps it in atomicallyThis is intentionally “refresh-on-demand” for simplicity (no file watching yet).
The query parameter uses SQLite FTS5 MATCH syntax and is not a substring/contains search.
-tags sqlite_fts5 to enable full-text search.query, the API returns an error.Ranking:
orderBy=rank orders by bm25 score (best matches first).GET /api/v1/search/docs supports cursor-based pagination:
pageSize + cursornextCursor (opaque; pass it back as cursor to fetch the next page)For v1, cursors may be implemented internally using offsets but are treated as opaque by clients.
docmgr api serve --addr 127.0.0.1:8787 --root ttmp
Flags:
--addr: bind address (default 127.0.0.1:8787)--root: docs root directory (default ttmp)--cors-origin: if set, adds CORS headers for browser-based UIsBase path: /api/v1
GET /api/v1/healthz
Response:
{ "ok": true }
GET /api/v1/workspace/status
Purpose: show which workspace is currently indexed and basic index metadata.
Response (shape):
{
"root": "/abs/path/to/ttmp",
"configDir": "/abs/path",
"repoRoot": "/abs/path/to/repo",
"configPath": "/abs/path/to/.ttmp.yaml",
"vocabularyPath": "/abs/path/to/ttmp/vocabulary.yaml",
"indexedAt": "2026-01-04T21:05:04.583Z",
"docsIndexed": 200,
"ftsAvailable": true
}
GET /api/v1/workspace/summary
Purpose: render the Workspace home/dashboard with one call (basic stats + recent tickets + recent docs).
Response (shape):
{
"root": "/abs/path/to/ttmp",
"repoRoot": "/abs/path/to/repo",
"indexedAt": "2026-01-05T00:00:00Z",
"docsIndexed": 413,
"stats": {
"ticketsTotal": 128,
"ticketsActive": 12,
"ticketsComplete": 84,
"ticketsReview": 9,
"ticketsDraft": 23
},
"recent": {
"tickets": [
{
"ticket": "001-ADD-DOCMGR-UI",
"title": "Add docmgr Web UI",
"status": "active",
"topics": ["docmgr", "ui"],
"owners": [],
"intent": "long-term",
"createdAt": "2026-01-03",
"updatedAt": "2026-01-05T00:00:00Z",
"ticketDir": "2026/01/03/001-ADD-DOCMGR-UI--add-docmgr-web-ui",
"indexPath": "2026/01/03/001-ADD-DOCMGR-UI--add-docmgr-web-ui/index.md",
"snippet": "",
"stats": null
}
],
"docs": [
{
"path": "2026/01/03/.../design/03-workspace-rest-api.md",
"ticket": "001-ADD-DOCMGR-UI",
"title": "Design: docmgr Workspace REST API (for full-site navigation)",
"docType": "design-doc",
"status": "active",
"topics": ["docmgr", "ui", "http", "api", "workspace"],
"updatedAt": "2026-01-05T00:00:00Z"
}
]
}
}
POST /api/v1/index/refresh
Response (shape):
{
"refreshed": true,
"indexedAt": "2026-01-04T21:05:04.583Z",
"docsIndexed": 200,
"ftsAvailable": true
}
GET /api/v1/workspace/tickets
Purpose: list tickets (workspace-wide) derived from the ticket index.md docs (DocType: index).
Query parameters:
status (string): active|review|complete|draft| (empty = all)ticket (string): exact ticket ID match (optional)topics (string): comma-separated, match any topic (optional)owners (string): comma-separated, match any owner (optional)intent (string): exact match (optional)q (string): full-text query (FTS5) applied to index docs only (optional)orderBy (string): last_updated|ticket|title (default last_updated)reverse (bool, default false)includeArchived (bool, default true)includeStats (bool, default false): when true, computes per-ticket stats (tasks/docs/related files)pageSize (int, default 200, max 1000)cursor (string, optional)Response (shape):
{
"query": {
"q": "",
"status": "active",
"ticket": "",
"topics": ["docmgr", "ui"],
"owners": [],
"intent": "",
"orderBy": "last_updated",
"reverse": false,
"includeArchived": true,
"includeStats": false,
"pageSize": 200,
"cursor": ""
},
"total": 128,
"results": [
{
"ticket": "001-ADD-DOCMGR-UI",
"title": "Add docmgr Web UI",
"status": "active",
"topics": ["docmgr", "ui"],
"owners": [],
"intent": "long-term",
"createdAt": "2026-01-03",
"updatedAt": "2026-01-05T00:00:00Z",
"ticketDir": "2026/01/03/001-ADD-DOCMGR-UI--add-docmgr-web-ui",
"indexPath": "2026/01/03/001-ADD-DOCMGR-UI--add-docmgr-web-ui/index.md",
"snippet": "",
"stats": null
}
],
"nextCursor": ""
}
GET /api/v1/workspace/facets
Purpose: drive Workspace filters (statuses/docTypes/intents/topics/owners).
Query parameters:
includeArchived (bool, default true)Response (shape):
{
"statuses": ["active", "review", "complete", "draft"],
"docTypes": ["index", "design-doc", "reference", "analysis", "sources"],
"intents": ["short-term", "long-term", "evergreen"],
"topics": ["docmgr", "ui", "tooling"],
"owners": ["manuel", "alex"]
}
Notes:
vocabulary.yaml for statuses/docTypes/intents/topics when present, but falls back to deriving from indexed docs.GET /api/v1/workspace/recent
Purpose: show “recently updated tickets” and “recently updated docs”.
Query parameters:
ticketsLimit (int, default 20, max 1000)docsLimit (int, default 20, max 1000)includeArchived (bool, default true)Response (shape):
{
"tickets": [ /* TicketListItem[] (same as /workspace/tickets results) */ ],
"docs": [
{
"path": "2026/01/03/.../design/03-workspace-rest-api.md",
"ticket": "001-ADD-DOCMGR-UI",
"title": "Design: docmgr Workspace REST API (for full-site navigation)",
"docType": "design-doc",
"status": "active",
"topics": ["docmgr", "ui"],
"updatedAt": "2026-01-05T00:00:00Z"
}
]
}
GET /api/v1/workspace/topics
Purpose: list topics (workspace-wide) with basic counts.
Query parameters:
includeArchived (bool, default true)Response (shape):
{
"total": 42,
"results": [
{ "topic": "docmgr", "docsTotal": 120, "ticketsTotal": 14, "updatedAt": "2026-01-05T00:00:00Z" }
]
}
GET /api/v1/workspace/topics/get
Query parameters:
topic (string, required)includeArchived (bool, default true)docsLimit (int, default 20, max 1000)Response (shape):
{
"topic": "docmgr",
"stats": {
"ticketsTotal": 14,
"ticketsActive": 6,
"ticketsComplete": 4,
"ticketsReview": 2,
"ticketsDraft": 2
},
"tickets": [ /* TicketListItem[] */ ],
"docs": [ /* RecentDocItem[] */ ]
}
GET /api/v1/search/docs
Query parameters:
query (string): FTS5 MATCH query stringticket (string)topics (string): comma-separateddocType (string)status (string)file (string): reverse lookupdir (string): reverse lookupexternalSource (string)since (string)until (string)createdSince (string)updatedSince (string)Visibility toggles (defaults mirror CLI behavior):
includeArchived (bool, default true)includeScripts (bool, default true)includeControlDocs (bool, default true)includeDiagnostics (bool, default true)includeErrors (bool, default false)Sorting:
orderBy: path|last_updated|rank (default path)reverse (bool, default false)Reverse lookup notes:
reverse=true searches docs by RelatedFiles references.file or dir.reverse=true and file/dir are empty but query is set, the server treats query as file.Pagination:
pageSize (int, default 200, max 1000)cursor (string, optional)Response (shape):
{
"query": { "query": "websocket", "pageSize": 50, "cursor": "" },
"total": 12,
"results": [
{
"ticket": "MEN-4242",
"title": "Chat WebSocket Lifecycle",
"docType": "reference",
"status": "active",
"topics": ["chat", "backend", "websocket"],
"path": "2026/01/04/MEN-4242--.../reference/01-chat-websocket-lifecycle.md",
"lastUpdated": "2026-01-04T15:04:05Z",
"snippet": "...",
"relatedFiles": [
{ "path": "backend/chat/ws/manager.go", "note": "WebSocket lifecycle (scenario)" }
],
"matchedFiles": ["backend/chat/ws/manager.go"],
"matchedNotes": ["WebSocket lifecycle (scenario)"]
}
],
"diagnostics": [],
"nextCursor": "..."
}
GET /api/v1/search/files
Query parameters:
query (string)ticket (string)topics (string): comma-separatedlimit (int, default 200, max 1000)Response (shape):
{
"total": 3,
"results": [
{ "file": "backend/chat/ws/manager.go", "source": "related_files", "reason": "..." }
]
}
GET /api/v1/docs/get
Query parameters:
path (string, required): doc-relative path under the docs root (same value as SearchDocResult.path)Response (shape):
{
"path": "2026/01/03/TICKET--slug/design/01-doc.md",
"doc": {
"title": "...",
"ticket": "...",
"status": "...",
"topics": ["..."],
"docType": "...",
"intent": "...",
"owners": ["..."],
"relatedFiles": [{ "path": "internal/foo.go", "note": "..." }],
"externalSources": [],
"summary": "",
"lastUpdated": "2026-01-04T19:22:44-05:00",
"whatFor": "",
"whenToUse": ""
},
"relatedFiles": [{ "path": "internal/foo.go", "note": "..." }],
"body": "# Markdown…",
"stats": { "sizeBytes": 12345, "modTime": "2026-01-04T19:22:44-05:00" },
"diagnostic": null
}
Notes:
doc will be omitted and diagnostic may be present; body still returns the markdown body (best-effort).GET /api/v1/files/get
Query parameters:
path (string, required): a file path (repo-relative is recommended; absolute paths are only accepted if they resolve inside the allowed root)root (string, optional): repo|docs (default repo)Response (shape):
{
"path": "internal/httpapi/server.go",
"root": "repo",
"language": "go",
"contentType": "text/x-go; charset=utf-8",
"truncated": false,
"content": "package httpapi\n...",
"stats": { "sizeBytes": 12345, "modTime": "2026-01-04T19:22:44-05:00" }
}
Safety behavior:
unsupported_media_type).truncated).GET /api/v1/files/raw
Streams a file's bytes with a sniffed Content-Type — no JSON envelope. This
is what the web UI uses to load relative images referenced from markdown
bodies; any binary type is fine (images, PDFs, ...).
Query parameters:
path (string, required): file path relative to the chosen rootroot (string, optional): repo|docs (default repo)Safety behavior:
/files/get (path traversal and
symlink escapes are rejected with 403 forbidden).413 too_large).curl -s "http://127.0.0.1:8787/api/v1/files/raw?root=docs&path=2026/01/03/TICKET--slug/images/diagram.png" -o diagram.png
POST /api/v1/docs/meta
Wraps the docmgr meta update write primitive: updates one frontmatter field
of a document under the docs root, bumps LastUpdated, and refreshes the
in-memory index so subsequent reads see the change.
Request body:
{ "path": "2026/01/03/TICKET--slug/index.md", "field": "Status", "value": "review" }
field accepts the same names as the CLI: Title, Ticket, Status,
Topics, DocType, Intent, Owners, RelatedFiles,
ExternalSources, Summary (case-insensitive; list fields take
comma-separated values). Unknown fields return 400 invalid_argument.Response:
{ "path": "2026/01/03/TICKET--slug/index.md", "field": "Status", "value": "review", "status": "updated" }
POST /api/v1/docs/relate
Wraps the docmgr doc relate write primitive, including anchored writes: new
entries are persisted in anchored form (repo://..., ws://...,
docs://..., abs://...), duplicates collapse by resolved absolute path,
and notes merge. Refreshes the index afterwards.
Request body:
{
"path": "2026/01/03/TICKET--slug/index.md",
"add": [{ "path": "internal/httpapi/server.go", "note": "route registration" }],
"remove": ["pkg/old/file.go"]
}
Response:
{ "path": "2026/01/03/TICKET--slug/index.md", "added": 1, "updated": 0, "removed": 1, "total": 3, "status": "updated" }
status is "noop" when nothing changed (e.g. the entry already existed
with the same note).
GET /api/v1/tickets/changelog?ticket=TICKET-123
Returns the ticket's changelog.md parsed into its dated ## YYYY-MM-DD[ - Title]
sections, in file order (appends put the newest entry last):
{
"ticket": "TICKET-123",
"exists": true,
"path": "2026/01/03/TICKET-123--slug/changelog.md",
"entries": [
{ "date": "2026-07-05", "title": "First pass", "heading": "2026-07-05 - First pass", "body": "Did the thing.\n\n### Related Files\n..." }
]
}
POST /api/v1/tickets/changelog
Appends an entry via the docmgr changelog update primitive (creates
changelog.md with a header when missing) and refreshes the index:
{ "ticket": "TICKET-123", "title": "Optional title", "entry": "What changed." }
Response: { "ok": true, "ticket": "TICKET-123", "path": "...", "date": "2026-07-05" }.
Empty entry returns 400 invalid_argument.
GET /api/v1/workspace/doctor
Runs the docmgr doctor scan (read-only — no --fix) and returns its
findings as JSON using the same row model as the CLI: a per-ticket rollup
plus the per-finding list.
Query parameters:
ticket (string, optional): restrict to one ticket (forgiving resolution)staleAfter (int, optional): staleness threshold in days (default 30)Response (shape):
{
"ticket": "",
"totals": { "findings": 12, "errors": 1, "warnings": 9, "infos": 0, "ticketsChecked": 4 },
"rollup": [
{ "ticket": "TICKET-123", "errors": 0, "warnings": 3, "infos": 0, "status": "warning" }
],
"findings": [
{ "ticket": "TICKET-123", "issue": "missing_related_file", "severity": "warning", "message": "related file not found: pkg/gone.go", "path": "..." }
]
}
All error responses use a stable JSON envelope:
{
"error": {
"code": "invalid_argument",
"message": "must provide at least a query or filter",
"details": {}
}
}
Common error codes:
index_not_ready (503): the index is not initializedinvalid_cursor (400): cursor is malformedfts_not_available (400): request uses query but FTS is unavailableinternal (500): unexpected server errorfts_not_availableBuild with -tags sqlite_fts5 and restart the server:
go build -tags sqlite_fts5 -o /tmp/docmgr ./cmd/docmgr
/tmp/docmgr api serve --addr 127.0.0.1:8787 --root ttmp
Call refresh:
curl -s -X POST http://127.0.0.1:8787/api/v1/index/refresh