Knowtation Hub API
This document defines the Hub REST API contract and auth model for Phase 11. The same contract is implemented by (a) the self-hosted Node server (Docker) and (b) the ICP canister(s). The Hub UI and CLI talk to either deployment using the same routes and JSON shapes.
Reference: AGENT-INTEGRATION.md §4 (proposals), PROPOSAL-LIFECYCLE.md, SPEC.md §4 (CLI semantics).
1. Authentication
1.1 Model
- Login required. There is no API-key-only path; all Hub API calls require a JWT obtained after login.
- Self-hosted (Docker): Login via OAuth 2.0 (Google and/or GitHub). After successful OAuth callback, the server issues a JWT (access token). Optional: refresh token for long-lived sessions.
- Hosted (ICP): Login via Internet Identity (or, if fronted by a gateway that performs OAuth, a JWT trusted by the canister). The canister validates the JWT or II delegation.
1.2 Obtaining a JWT
| Deployment | Flow |
|---|---|
| Self-hosted | User visits GET /auth/login (or equivalent); redirect to OAuth provider; callback at GET /auth/callback; server issues JWT, sets cookie or returns token in response body. |
| ICP | User signs in with Internet Identity; front-end receives session; subsequent API calls include the II-derived principal or a JWT issued by an auth canister. |
1.3 Using the JWT
- Header:
Authorization: Bearer <access_token> - All Hub API endpoints (except login/callback and public health) require this header. Missing or invalid token →
401 Unauthorized.
1.4 Token lifetime and refresh
- Access token: Short-lived (e.g. 15–60 minutes). Document exact lifetime in deployment config.
- Refresh token (optional): If supported, store securely; use to obtain a new access token via
POST /auth/refresh(body:{ "refresh_token": "..." }). Refresh tokens are long-lived until revoked.
1.5 Scopes (optional)
JWTs may include scopes to distinguish read vs write vs propose:
read— list notes, get note, search.write— write note (direct to vault).propose— create proposal, list own proposals.review— list all proposals, approve, discard.
If not implemented in v1, all authenticated users have full access. Document scope semantics when added.
2. Base URL and versioning
- Base URL: Self-hosted:
http(s)://<host>:<port>/api(or no prefix). ICP:https://<canister-id>.ic0.app/api(or as deployed). - Versioning: Path prefix
/api/v1recommended (e.g.GET /api/v1/notes). Omit version in this doc for brevity; implementations use a consistent prefix. - Vault context (multi-vault / hosted): Optional header
X-Vault-Idor query paramvault_idto scope requests to a vault. When absent, implementations use a default (e.g.defaultor the single vault). Gateway forwards JWTsubasx-user-idto the canister for trusted identity.
3. Endpoints (contract)
Same semantics as CLI where applicable. Request/response JSON matches SPEC §4.2 shapes where noted.
3.1 Health (no auth)
GET /health — Returns
200and{ "ok": true }if the Hub is up. No JWT required.GET /api/v1/auth/providers (no auth) — Which OAuth providers are configured. Response:
{ "google": boolean, "github": boolean }. The Rich Hub UI uses this to show Continue with Google / Continue with GitHub only when env vars are set; if both arefalse, the UI explains how to configure OAuth (no separate sign-up — identity is Google or GitHub only).
3.2 Vault read
GET /notes/facets — Returns
{ projects: string[], tags: string[], folders: string[] }for filter dropdowns. JWT required.GET /vault/folders — Self-hosted: returns
{ "folders": string[] }of vault-relative directory prefixes for the active vault (inboxfirst, then other top-level dirs and eachprojects/<name>). Hidden directories (names starting with.) are omitted. Used by the Hub New note folder picker; includes empty folders. Hosted gateway returns{ "folders": ["inbox"] }(no canister filesystem). JWT and vault access required.GET /notes — List notes. Query params:
folder,project,tag,since,until,chain,entity,episode,limit,offset,order(date|date-asc),fields(path|path+metadata|full),count_only,content_scope(allimplicit |notes|approval_logs) — narrow to normal notes vs materialized approval logs underapprovals/(see approve response).
Response:{ "notes": [ ... ], "total": number }or{ "total": number }ifcount_only=true. Per-note shape per SPEC §4.2 list-notes.GET /notes/:path — Get one note by vault-relative path. Path must be URL-encoded. Response:
{ "path": "...", "frontmatter": { ... }, "body": "..." }per SPEC §4.2 get-note. Hosted gateway responses normalize canister wire frontmatter into a JSON object even when the canister stores it as JSON text. 404 if not found.GET /section-source?path=... — Get body-free SectionSource metadata for one authorized vault-relative note. JWT required. Hosted gateway only in Phase 1N; no canister route is added.
Response:knowtation.section_source/v0with{ "schema", "path", "title", "sections", "truncated" }; each section includes only{ "section_id", "heading_id", "level", "heading_path", "heading_text", "child_section_ids", "body_available", "body_returned": false, "snippet_returned": false }.
The response excludes note body text, section body text, snippets, full frontmatter, line ranges, byte offsets, section body lengths, absolute paths, raw canister payloads, provider payloads, and MCP resource URIs.
400 ifpathis missing or unsafe; 401 if JWT is missing or invalid; 403 if vault access or the upstream note read is forbidden; 404 if the note is missing or outside scoped access; 502 for sanitized upstream failures.POST /search — Vault search. Default is semantic (vector similarity; requires index on self-hosted; hosted bridge uses per-user vector store). Set
"mode": "keyword"for keyword search: case-insensitive match over path, body, and selected frontmatter strings (title,intent,tags, etc.), with the same structural filters as list-notes. Optionalmatch(keyword only):"phrase"(default, whole query as substring) or"all_terms"(every whitespace-separated token must appear, AND). Body also supports:"folder?","project?","tag?","limit?","since?","until?","order?","fields?","chain?","entity?","episode?","snippetChars?",content_scope(notes|approval_logs),count_only/countOnly.
Response:{ "results": [ { "path", "snippet?", "score", "project", "tags" } ], "query": "...", "mode": "semantic" | "keyword" }; keyword responses may include"count"whencount_onlyis true. Per SPEC §4.2 search where applicable.
400 if query missing.
3.3 Vault write
POST /notes — Write or update a note. Body:
{ "path": "...", "body?", "frontmatter?", "append?" }. Path vault-relative.
The Hub merges server provenance into frontmatter:knowtation_editor(JWTsub),knowtation_edited_at,author_kind: human. Client-supplied values for those keys (and other reservedknowtation_*fields) are ignored.
Response:{ "path": "...", "written": true }.
400 if path invalid; 403 if not allowed.POST /notes/batch — Write many notes in one update (ICP canister: single
saveStable()after all puts). Body:{ "notes": [ { "path", "body", "frontmatter?" }, ... ] }. Preferfrontmatteras a JSON object (same as gatewayPOST /notes). Max 100 items per request; hosted bridge chunks larger imports. Response:{ "imported": number, "written": true }. 400 if JSON invalid or over limit.DELETE /notes/:path — Remove one note by vault-relative path (URL-encoded, same as GET). Editor or admin only (same write gate as
POST /notes). Response:{ "path": "...", "deleted": true }. 404 if the note does not exist; 400 if path is invalid. Hosted semantic search: the bridge vector index is not updated automatically; after deletes, run Re-index so meaning-search does not return stale hits for removed paths (see bridge indexer behavior).POST /notes/delete-by-prefix — Bulk delete by note path string (vault-relative), not by Hub filter metadata. A note’s path is the key used in
POST /notes(e.g.inbox/capture.mdorprojects/acme/plan.md). This endpoint deletes every.mdfile whose path equalspath_prefixor starts withpath_prefix/after trimming slashes. It does not look at frontmatterproject:ortags:— notes that only set Project in the Hub UI but live underinbox/...are not matched by a prefix likeprojects/my-slugunless their paths actually sit under that folder. Editor or admin only. Body:{ "path_prefix": "projects/acme" }(example: delete everything under theprojects/acme/folder layout). Response:{ "deleted": number, "paths": string[], "proposals_discarded": number }(self-hosted Hub also discards proposed proposals whosepathequals one of the deleted paths or was already under the prefix). 400 ifpath_prefixis invalid. On hosted (ICP), the same path-string rule applies: there is no filesystem, but each note still has a stored path string. Semantic search: run Re-index after bulk delete so search does not return stale paths.POST /notes/delete-by-project — Bulk delete every markdown note in the current vault whose effective project slug matches the request (same rules as
GET /notes?project=— frontmatterprojectand path inference underprojects/<slug>/per SPEC.md). Editor or admin only (hosted:viewerdenied). Body:{ "project": "my-slug" }(slug normalized like list-notes). Response:{ "deleted": number, "paths": string[], "proposals_discarded": number }. Self-hosted: Node Hub. Hosted: gateway orchestrates the canister (not a Motoko route). The Hub static bundle must include PR #65 so Settings on hosted issues these POSTs (see HUB-METADATA-BULK-OPS.md).POST /notes/rename-project — Rewrites frontmatter
projectfromfromtotofor every note in the current vault whose effective project slug matchesfrom(does not move files on disk / path keys on canister). Editor or admin only. Body:{ "from": "old-slug", "to": "new-slug" }. Response:{ "updated": number, "paths": string[] }. Self-hosted: Node Hub. Hosted: gateway orchestratesPOST /notesper matching path; same Hub client requirement as delete-by-project (HUB-METADATA-BULK-OPS.md).POST /index — Re-run the indexer (vault → chunk → embed → vector store). Use after bulk imports or when search should reflect new or changed notes. JWT required.
Response:{ "ok": true, "notesProcessed": number, "chunksIndexed": number }.
500 on indexer or config failure.POST /export — Export one note to downloadable content (editor/admin). Body:
{ "path": string, "format"?: "md" | "html" }.
Response:{ "content": string, "filename": string }. Client may create a blob and trigger download.
400 if path invalid; 404 if note not found.
Hosted: the gateway implements this (fetch note from the canister, then build Markdown/HTML) because the ICP canister’sGET /api/v1/exportis a full-vault JSON export, not a single-file download; a bare POST to the canister would return 404.POST /notes/copy — Copy or move a single note from one vault to another for the same user/team (editor/admin; viewer denied). Body:
{ "from_vault_id": string, "to_vault_id": string, "path": string, "delete_source"?: boolean }.
pathis vault-relative (same as GET/POST note).delete_source: trueperforms a move: after a successful write to the target vault, the source note is deleted.
Access:from_vault_idandto_vault_idmust both appear in the sessionallowed_vault_ids(hosted: bridgehosted-context; self-hosted:hub_vault_access/ defaults).
Conflicts: Ifpathalready exists in the target vault, the operation overwrites that note (same semantics asPOST /notes).
Response (success):{ "ok": true, "path", "from_vault_id", "to_vault_id", "moved": boolean }.
400 if vault ids match, path invalid, or body incomplete; 403 if role or vault access fails; 404 if source note not found or outside scope; 502 withDELETE_FAILEDif the copy succeeded but source delete failed (move).
Hosted: implemented on the gateway (GET source note from canister → POST to target → optional DELETE); not a native canister multi-vault call. After success, the gateway triggers Re-index on the bridge for the target vault and, when moving, the source vault (asynchronous; same expectation as other hosted writes for semantic search).
Self-hosted: Node Hub reads/writes filesystem vaults on disk (hub/server.mjs).POST /import — Import from uploaded file or ZIP (editor/admin). Multipart form:
source_type(required),file(required),project?,output_dir?,tags?(comma-separated). Source types includemarkdown,pdf,docx,url,chatgpt-export,claude-export,mif,mem0-export,supabase-memory,notion,jira-export,notebooklm,gdrive,linear-export,audio,video,wallet-csv(seelib/import-source-types.mjs). If file is a ZIP, it is extracted and the extracted folder is used as input (for folder-based sources like chatgpt-export). Forpdf, upload a single.pdffile (not a ZIP). Fordocx, upload a single.docxfile (Office Open XML; not legacy.doc).
After import, the Hub runs a provenance pass on each imported path (author_kind: import, editorsub).
Response:{ "imported": [ { "path", "source_id?" } ], "count": number }.
400 if file or source_type missing/invalid; 500 on import failure.POST /import-url — Import a public https URL into the vault (editor/admin). JSON body:
{ "url": string, "mode"?: "auto" | "bookmark" | "extract", "project"?, "output_dir"?, "tags"? }(tagsmay be a comma-separated string or string array). Server-side fetch with SSRF protections; article extraction whenmodeallows. Same provenance pass and response shape as POST /import. Hosted: gateway proxies to bridge whenBRIDGE_URLis set.
3.3.0 Billing (Phase 16 hosted)
GET /billing/summary — JWT required. Hosted gateway only.
Response:{ "tier", "period_start?", "period_end?", "monthly_included_cents", "monthly_included_effective_cents", "monthly_used_cents", "addon_cents", "billing_enforced", "stripe_configured", "credit_policy", "monthly_indexing_tokens_included" (number or **null** for beta = unlimited display), "monthly_indexing_tokens_used", "pack_indexing_tokens_balance", "indexing_tokens_policy", "cost_breakdown": [ … ], "usage_chart_status" }. Free tier:monthly_included_effective_centsreflects the $0 tier allowance.monthly_indexing_tokens_usedincrements after each successful hosted Re-index when the bridge returnsembedding_input_tokens. See HOSTED-CREDITS-DESIGN.md.POST /billing/webhook — Stripe webhook endpoint; no JWT. Expects raw JSON body (signature verification). Not used on self-hosted Node Hub unless you expose the same route.
3.3.1 Settings and vault backup (JWT required)
GET /settings — Safe config status for the Settings UI. No secrets or full paths.
Response: addsproposal_policy_stored{ "proposal_evaluation_required", "review_hints_enabled", "enrich_enabled" }(values saved for the admin checkboxes) andproposal_policy_env_lockedwith the same keys (truewhere an explicit host env value overrides the file/prefs). Other fields unchanged:"role", "user_id", "vault_id", "vault_list": [ { "id", "label?" } ], "allowed_vault_ids", "vault_path_display", "vault_git", "github_connect_available", "github_connected", "embedding_display", "proposal_enrich_enabled", "proposal_evaluation_required", "proposal_review_hints_enabled", "hub_evaluator_may_approve", "proposal_rubric": { "items": [ { "id", "label" } ] } }. Phase 15:vault_listandallowed_vault_idsdrive the vault switcher; requests use X-Vault-Id to scope to a vault. Proposal LLM + gate: effectiveproposal_*_enabled/proposal_evaluation_requiredfollow lib/hub-proposal-policy.mjs on self-hosted (env overridesdata/hub_proposal_policy.json). Hosted gateway: same env keys override persisted prefs indata/hosted_proposal_llm_prefs.jsonor Netlify Blob (hub/gateway/proposal-llm-store.mjs).proposal_rubricis the merged default + optionaldata/hub_proposal_rubric.json(see PROPOSAL-LIFECYCLE.md).POST /settings/proposal-policy — Admin only. On the hosted gateway, “admin” means JWT from
HUB_ADMIN_USER_IDSor bridgeGET /api/v1/rolereturningrole: admin(Team tab). Body:{ "proposal_evaluation_required"?: boolean, "review_hints_enabled"?: boolean, "enrich_enabled"?: boolean }. Merges intodata/hub_proposal_policy.json(Node Hub) or hosted prefs store (gateway). Fields locked by explicit env on the host are ignored. Response:{ "ok": true }.POST /vault/sync — Run manual vault sync (same as
knowtation vault sync): git add, commit, push. Use for "Back up now" in Settings.
Response:{ "ok": true, "message": "Synced" | "Nothing to commit" }.
400 if vault.git not configured; 500 on git failure.
To set the repository: (1) Use Settings → Setup in the Hub to write vault path and Git remote to data/hub_setup.yaml (applied immediately). (2) Or edit config/local.yaml (see PROVENANCE-AND-GIT.md and How to use → Step 7). Connect GitHub (Settings): if the Hub has GitHub OAuth configured, users can click "Connect GitHub" to authorize with scope=repo; the Hub stores the token in data/github_connection.json and uses it for push so no deploy key is needed. Add callback URL .../api/v1/auth/callback/github-connect to your GitHub OAuth App.
- GET /setup — Editable setup (vault_path, vault_git) for the Setup wizard. Returns current values.
- POST /setup — Body:
{ vault_path?, vault_git?: { enabled?, remote? } }. Writes todata/hub_setup.yamland reloads config (no restart). 400 if invalid; 500 on write failure.
3.3.2 Multi-vault admin (Phase 15; admin only)
- GET /vaults — List vaults (from
data/hub_vaults.yamlor default single vault). Response:{ "vaults": [ { "id", "path", "label?" } ] }. - POST /vaults — Body:
{ "vaults": [ { "id", "path", "label?" } ] }. Writesdata/hub_vaults.yaml. At least one vault must have iddefault. 400 if invalid. - DELETE /vaults/:vaultId — Permanently remove a non-default vault. Self-hosted (Node Hub): admin only. Deletes the vault directory on disk (must resolve under the project root), removes the entry from
hub_vaults.yaml, stripsvaultIdfromhub_vault_access.jsonandhub_scope.json, removes proposals for that vault, and deletes vector-index rows for thatvault_id(sqlite-vec / Qdrant). 400 ifvaultIdisdefaultor unknown, or if two YAML entries share the same path. 403 if the resolved vault path is outside the project root. Response:{ "ok": true, "deleted_vault_id", "proposals_removed", "vectors_purged" }. Hosted: WhenBRIDGE_URLis set, the gateway proxies DELETE to the bridge, which calls the canister (Motoko upgrade), then updates teamhub_vault_access/hub_scopeand removes the per-user vector blob for that vault. Editor or admin (viewer denied); workspace owner required whenworkspace_owner_idis set (same as creating a cloud vault). Does not delete a linked GitHub repo. ICP: Deploy an upgraded canister that implements this route before hosted delete works in production. - GET /vault-access — User → allowed vault IDs. Response:
{ "access": { "user_id": [ "vault_id", ... ] } }. - POST /vault-access — Body:
{ "access": { "user_id": [ "vault_id", ... ] } }. Writesdata/hub_vault_access.json. - GET /scope — Per-user per-vault scope (projects/folders). Response:
{ "scope": { "user_id": { "vault_id": { "projects": [], "folders": [] } } } }. - POST /scope — Body:
{ "scope": { ... } }. Writesdata/hub_scope.json.
3.3.3 Hosted workspace owner and delegation (bridge + gateway)
On hosted, vault-access and scope JSON persist in the bridge (same shapes as §3.3.2). The gateway proxies these routes when BRIDGE_URL is set. Workspace owner controls which canister partition is shared with the team:
- GET /workspace — Admin. Response:
{ "owner_user_id": string | null }. - POST /workspace — Admin. Body
{ "owner_user_id": string | null }.nulldisables delegation (each user uses only their own canister id).
GET /hosted-context — JWT. Returns { "actor_sub", "workspace_owner_id", "effective_canister_user_id", "delegating", "allowed_vault_ids", "scope": { "projects", "folders" } | null, "role" } for the current X-Vault-Id header (default default). Used by the gateway and for debugging.
Gateway → canister headers: X-User-Id = effective partition owner; X-Actor-Id = JWT sub (human/agent who performed the action). Full semantics: MULTI-VAULT-AND-SCOPED-ACCESS.md, TEAMS-AND-COLLABORATION.md.
3.4 Proposals
Variation protocol (Muse-aligned). Proposals implement a variation lifecycle compatible with Muse: identifiers — proposal_id (variation id), base_state_id (optional, for optimistic concurrency); intent — human- or agent-readable reason for the change; lifecycle — propose → review → approve or discard. Default deployments do not run Muse; we align our API and payload so we can interoperate or adopt Muse later. Optional external_ref (e.g. future Muse commit id) may be added for cross-system references.
Lifecycle reference: PROPOSAL-LIFECYCLE.md (states, roles, kn1_ / base_state_id semantics).
Optional Muse linkage (operators). A deployment may configure a read-only connection to a Muse instance for lineage / structural history queries (e.g. Git-replayed history in Muse’s model). That path is not required for JWT login, proposal CRUD, vault writes, or search. See AGENT-INTEGRATION.md §4 (Optional external lineage) and MUSE-THIN-BRIDGE.md (env, approve behavior, admin proxy).
POST /proposals — Create a proposal (variation). Body:
{ "path?", "body?", "frontmatter?", "intent?", "base_state_id?", "external_ref?", "labels?" (string[]), "source?" (e.g. agent|human|import) }. If path omitted, proposal may be a new note (server assigns path or client sends path).
Response:{ "proposal_id": "...", "path": "...", "status": "proposed", ... }.
201 (Node Hub) / 200 (some proxies). 400 if invalid.
Policy + triggers: lib/hub-proposal-policy.mjs and lib/hub-proposal-review-triggers.mjs setevaluation_status,review_queue,review_severity,auto_flag_reasonson create;proposal_auto_flaggedis audited when reasons are non-empty. Hosted gateway applies the same rules to the JSON body before the canister. See PROPOSAL-LIFECYCLE.md.GET /proposals — List proposals. Query:
status(e.g.proposed,approved,discarded),limit,offset,label,source,path_prefix,evaluation_status,review_queue,review_severity(standard|elevated).
Response:{ "proposals": [ { …, "review_queue?", "review_severity?", "auto_flag_reasons?" (Node) or "auto_flag_reasons_json" (canister), … } ], "total": number }.GET /proposals/:id — Get one proposal (metadata + proposed content).
Response: includesbody,frontmatter, optionalsuggested_labels,assistant_notes,assistant_model,assistant_atwhen enrichment was run; optionalassistant_suggested_frontmatter(object: SPEC-aligned suggested note metadata, normalized server-side; absent or{}on older proposals); human evaluation fields; optionalreview_hints,review_hints_at,review_hints_model;auto_flag_reasons(Node) orauto_flag_reasons_json(canister).
404 if not found.POST /proposals/:id/evaluation — Admin or evaluator. Record a human evaluation. Body:
{ "outcome": "pass" | "fail" | "needs_changes", "checklist?": [ { "id", "passed": boolean } ], "grade?": string, "comment?": string }. Checklist ids should matchGET /settings→proposal_rubric.items. Pass requires every rubric itempassed: truewhen the rubric is non-empty. Fail / needs_changes require non-emptycomment.
Response: full proposal object (Node). 400 on validation errors; 404 if not found.POST /proposals/:id/approve — Apply proposal to vault. Admin, or evaluator when
HUB_EVALUATOR_MAY_APPROVE=1. Optional body:{ "base_state_id?", "waiver_reason?", "external_ref?" }.waiver_reason(trimmed length ≥ 3) allows approve whenevaluation_statusispending,failed, orneeds_changeswithout a prior pass; stored asevaluation_waiverand audited. If the effectivebase_state_idis non-empty, the self-hosted Node Hub recomputes the current note fingerprint (kn1_per PROPOSAL-LIFECYCLE.md) and returns 409CONFLICTwhen it does not match. Emptybase_state_idskips the check (backward compatible).
external_ref(optional Muse thin bridge): When set on approve (non-empty, normalized server-side), it is stored on the approved proposal for cross-system lineage (e.g. Muse commit/branch id). If omitted andMUSE_URLis set on the server, the Hub may perform a non-blockingGETto{MUSE_URL}/knowtation/v1/lineage-ref?proposal_id=…&vault_id=…(BearerMUSE_API_KEYwhen set); the JSON response fieldexternal_refis used when valid. Approve never fails if that request errors or returns nothing; see MUSE-THIN-BRIDGE.md. Hosted: the gateway merges the resolved value into the JSON forwarded to the canister before approve.
Response: full proposal JSON plusapproval_log_written(boolean), optionalapproval_log_path(vault-relativeapprovals/YYYY-MM-DD-<proposal_id>.md), andapproval_log_errorwhen the log file could not be written (approve still completes). Hosted canister: JSON includesexternal_refon success; also returnsapproval_log_pathandapproval_log_written: truewhen the second vault put succeeds.
403EVALUATION_REQUIREDwhen evaluation blocks approve and waiver is missing/short. 409 if fingerprint mismatch.GET /operator/muse/proxy — Admin only. Read-only forward to the operator-configured
MUSE_URL. Query:path= URL-encoded path starting with an allowlisted prefix (default/knowtation/v1/; override with comma-separatedMUSE_PROXY_PATH_PREFIXES). Returns 404NOT_FOUNDwhenMUSE_URLis unset (no Muse-specific error text). Self-hosted Node Hub and hosted gateway both implement this route. See MUSE-THIN-BRIDGE.md.POST /proposals/:id/review-hints — (ICP canister) Internal/async: body
{ "review_hints", "review_hints_model" }stores non-authoritative hint text when review hints are enabled on the gateway (envKNOWTATION_HUB_PROPOSAL_REVIEW_HINTSor admin-saved prefs; see GET /settings) and the gateway schedules a follow-up after POST /proposals. Not a merge gate.POST /proposals/:id/discard — Discard proposal (do not apply). Admin (Node Hub).
Response:{ "proposal_id", "status": "discarded" }.POST /proposals/:id/enrich — (Optional Tier 2) When enrich is enabled (env
KNOWTATION_HUB_PROPOSAL_ENRICHor admin-saved prefs; see GET /settings), editor, admin, or evaluator may request an LLM summary, suggested labels, and suggested frontmatter (versioned JSON envelope parsed via lib/proposal-enrich-llm.mjs). 404 if the feature is disabled (NOT_FOUNDbody). Self-hosted: Node Hub runs the model and updates local proposal storage. Hosted: The gateway runscompleteChat(lib/llm-complete.mjs) and POSTs{ "assistant_notes", "assistant_model", "suggested_labels_json", "assistant_suggested_frontmatter_json" }to the canister (assistant_suggested_frontmatter_jsonis a JSON string of the normalized object, capped like Node); response is the same shape as GET /proposals/:id from the canister. Chat backends: OpenAI (OPENAI_API_KEY), else Anthropic (ANTHROPIC_API_KEY), else Ollama (local). Canister route stores enrich fields only (trusted caller is the gateway with user headers). ICP canister: hub/icp/src/hub/JsonValidate.mo validates thatsuggested_labels_jsonis a JSON array andassistant_suggested_frontmatter_jsonis a JSON object before persisting (invalid values are coerced to[]/{}; 400 if valid JSON but over 4000 / 14000 characters so nothing is truncated mid-token). GET /proposals/:id on the canister always splices valid JSON fragments for those two fields (legacy bad rows fall back to[]/{}). Suggestions are advisory — they are not merged into the vault on approve unless operators copy them manually (or a future product feature adds an explicit apply step).
3.5 Capture (webhook, no JWT)
- POST /api/v1/capture — Ingest message into vault inbox. Same contract as
scripts/capture-webhook.mjs.
Body:{ "body": string, "source_id?", "source?", "project?", "tags?" }.
Response:{ "ok": true, "path": "inbox/..." }.
Auth: IfCAPTURE_WEBHOOK_SECRETis set, requireX-Webhook-Secret: <secret>header. Otherwise unauthenticated (local dev).
3.6 Errors
- 401 — Missing or invalid JWT.
- 402 — (Phase 16 hosted, when
BILLING_ENFORCEis on) Quota / billing. JSON includes"code"::QUOTA_EXHAUSTED— The operation would exceed both the monthly included pool and add-on rollover credits for this period; user should buy add-on credits, upgrade tier, or wait for period reset. Primary code for “out of credits.”SUBSCRIPTION_TIER_LIMIT— (Optional / legacy) Tier does not allow this operation or subscription inactive; upgrade or subscribe.INSUFFICIENT_CREDITS— (Narrow) Add-on wallet cannot cover the remainder after monthly pool is exhausted (synonym of exhausted state; preferQUOTA_EXHAUSTEDfor new clients).
See HOSTED-CREDITS-DESIGN.md. When enforcement is off (beta default), gateway does not return 402 for billing.
- 403 — Forbidden (e.g. scope or vault permission).
- 404 — Note or proposal not found.
- 409 — Conflict (e.g. base_state_id mismatch on approve).
- 500 — Server error.
JSON error body: { "error": "message", "code": "ERROR_CODE" } (align with CLI --json errors).
4. Rich Hub UI (contract for UI)
The Hub UI consumes the above API. It must provide:
- Search bar — Calls
POST /searchwith user query; display results with path, snippet, score. - Category / filter picker — Filter notes by project, tag, or folder using
GET /notesquery params. - Quick add —
POST /notesfrom the UI: quick capture (inbox) and full new-note form (path, title, body, project, tags). - Browse modes — List (filtered rows), Calendar (month grid by note
date, day drill-down), Overview (dashboard cards + charts: by project, tags, month). - Filter presets — Save named filter combos in browser storage; quick filter chips for common project/tag/folder jumps.
- Task / proposal views:
- Suggested tasks — Proposals with
status=proposed(need review). - In progress — Proposals recently updated or in review.
- Problem areas — Failed/conflicting proposals or notes needing resolution (implementation-defined; e.g. proposals that failed approve due to conflict).
- Suggested tasks — Proposals with
- State and status — Every list and detail shows status (draft, proposed, approved, discarded) and, where relevant, base_state_id and intention.
- Actions — Approve/discard from proposal detail; open note; edit (if in scope) via write or proposal.
The UI is a single front-end; it is configured with the Hub base URL (self-hosted or ICP) and uses the same endpoints.
5. ICP-specific notes
- Internet Identity: On ICP, the auth canister (or gateway) produces a principal or JWT that the Hub canister(s) trust. Document the exact flow (II login → session/JWT → API calls) in deployment docs.
- CORS: Canisters must allow the Hub UI origin; self-hosted Node must set CORS for the UI origin.
- Storage: Vault and proposals on ICP are stored in canister state (e.g. Documents/Assets patterns from bornfree-hub). Same API contract; implementation in Motoko (or Rust).
5.1 Operator full export (ICP hub canister only)
Not implemented on the self-hosted Node Hub. Used for scheduled logical backups of all tenant user ids without stopping the canister. See OPERATOR-BACKUP.md.
GET /api/v1/operator/export- Headers:
X-Operator-Export-Key: <secret>— must match the value set viaadmin_set_operator_export_secret(see below). - Query:
cursor(optional, default0) — index into the sorted list of user ids;limit(optional, default100, max500) — page size. - Response
200: JSONformat_version: 3,kind: knowtation-operator-user-index,user_ids(array of strings),next_cursor(string, empty whendone),done(boolean),exported_at_ns(wall clock nanoseconds text). - Errors:
401if the key is wrong;503if the operator secret was never configured (operator_export_secretempty on canister).
- Headers:
After listing user ids, the operator runner calls existing GET /api/v1/export, GET /api/v1/vaults, and GET /api/v1/proposals with X-User-Id / X-Vault-Id per user (see lib/operator-full-export.mjs).
- Candid (controllers only):
admin_set_operator_export_secret(secret: text)— only the canister’s controllers may call this (viadfx canister call). Sets the shared secret checked byX-Operator-Export-Key. Do not expose the secret in client apps or public repos.
6. CLI integration
- knowtation hub status — Calls
GET /health(and optionally an authenticated endpoint) to report whether the Hub at configured URL is reachable and the user is logged in (if token available). - knowtation propose --hub <url> — Creates a proposal via
POST /proposals; requires Hub URL and credentials (token from login flow or env). Document in setup how to obtain and store the token for CLI use.
See SPEC.md for CLI behavior aligned with Hub routes.