Knowledge Bases API
Picora v0.17+ introduces Knowledge Bases (KB) — second-level namespaces that let you isolate documents by project, topic, or client workspace. The same filename (e.g. CLAUDE.md, .moraya/index.json) can exist independently in each KB.
KB is the foundation of the bidirectional sync protocol used by Moraya and any third-party client that wants to mirror a local directory tree to Picora cloud.
Resource model — KbItem
| Field | Type | Notes |
|---|---|---|
id | string (nanoid 21) | KB ID |
name | string | Display name (≤ 120 chars) |
slug | string | URL-safe identifier, immutable after creation. Auto-derived from name if omitted. |
description | string | null | Optional description (≤ 500 chars) |
docCount | int | Count of active docs (cached) |
sizeBytes | int | Total bytes of active docs (cached) |
isDefault | boolean | At most one default KB per user |
createdAt, updatedAt | ISO 8601 |
Resource model — KbManifestEntry
| Field | Type | Notes |
|---|---|---|
relativePath | string | POSIX relative path, NFC-normalized |
sourceHash | string | null | SHA-256 hex (64 chars); null for soft-deleted entries |
sizeBytes | int | null | Byte size; null for soft-deleted entries |
updatedAt | ISO 8601 | null | Last modified; null for soft-deleted entries |
deletedAt | ISO 8601 | null | Soft-delete timestamp; null for active docs |
Quotas
| Plan | KB count |
|---|---|
| trial | 5 |
| pro | 50 |
| pro_plus | 500 |
Endpoints
POST /v1/kbs — create
curl -X POST https://api.picora.me/v1/kbs \ -H "Authorization: Bearer sk_live_..." \ -H "Content-Type: application/json" \ -d '{"name": "Research Notes", "slug": "research-notes"}'Response (201):
{ "success": true, "data": { "id": "V1StGXR8_Z5jdHi6B-myT", "name": "Research Notes", "slug": "research-notes", "description": null, "docCount": 0, "sizeBytes": 0, "isDefault": false, "createdAt": "2026-04-29T08:00:00.000Z", "updatedAt": "2026-04-29T08:00:00.000Z" }}Slug rules: ^[a-z0-9][a-z0-9-]{0,62}[a-z0-9]$. Chinese/non-ASCII names require an explicit slug. If omitted, the server slugifies name (lowercase, spaces → -).
Errors: 403 KB_LIMIT_REACHED when the plan limit is hit; 409 KB_SLUG_TAKEN when the slug already exists for this user.
GET /v1/kbs — list
| Param | Type | Notes |
|---|---|---|
cursor | string | Pagination cursor from nextCursor |
limit | int | 1–50, default 20 |
sort | string | updated_at (default) | name |
curl https://api.picora.me/v1/kbs \ -H "Authorization: Bearer sk_live_..."GET /v1/kbs/:id — detail
curl https://api.picora.me/v1/kbs/V1StGXR8_Z5jdHi6B-myT \ -H "Authorization: Bearer sk_live_..."PATCH /v1/kbs/:id — update metadata
Modifiable fields: name, description, isDefault. slug cannot be changed after creation.
Setting isDefault: true automatically clears isDefault on all other KBs of the same user.
curl -X PATCH https://api.picora.me/v1/kbs/V1StGXR8_Z5jdHi6B-myT \ -H "Authorization: Bearer sk_live_..." \ -H "Content-Type: application/json" \ -d '{"name": "AI Research", "isDefault": true}'DELETE /v1/kbs/:id — delete
| Query param | Notes |
|---|---|
cascade=false | Default. Returns 409 KB_NOT_EMPTY if there are active docs |
cascade=true | Cascades soft-delete to all docs, cleans R2 storage, releases doc_count quota |
# Safe delete (fails if KB has documents)curl -X DELETE https://api.picora.me/v1/kbs/V1StGXR8_Z5jdHi6B-myT \ -H "Authorization: Bearer sk_live_..."
# Force delete with cascadecurl -X DELETE "https://api.picora.me/v1/kbs/V1StGXR8_Z5jdHi6B-myT?cascade=true" \ -H "Authorization: Bearer sk_live_..."Sync protocol
The sync protocol is a two-step pull-then-push loop:
1. GET /v1/kbs/:id/manifest?since=<lastSync> ← lightweight metadata only2. For each changed entry: - if deletedAt != null → delete local file - if sourceHash != local hash → GET /v1/kbs/:id/raw?path=...3. POST /v1/kbs/:id/sync ← push local changesGET /v1/kbs/:id/manifest — sync manifest
Returns lightweight metadata (no content) for all docs in the KB.
| Param | Type | Notes |
|---|---|---|
since | ISO 8601 | Incremental mode: returns entries where updated_at > since OR deleted_at > since (includes soft-deleted). Omit for full sync. |
cursor | string | Pagination cursor |
limit | int | 1–1000, default 200 |
# Full sync (first time)curl "https://api.picora.me/v1/kbs/V1StGXR8_Z5jdHi6B-myT/manifest" \ -H "Authorization: Bearer sk_live_..."
# Incremental synccurl "https://api.picora.me/v1/kbs/V1StGXR8_Z5jdHi6B-myT/manifest?since=2026-04-29T08:00:00.000Z" \ -H "Authorization: Bearer sk_live_..."Response:
{ "success": true, "data": { "kbId": "V1StGXR8_Z5jdHi6B-myT", "items": [ { "relativePath": "notes/idea.md", "sourceHash": "a3f2b1c4d5e6...", "sizeBytes": 1024, "updatedAt": "2026-04-29T10:00:00.000Z", "deletedAt": null }, { "relativePath": "archive/old.md", "sourceHash": null, "sizeBytes": null, "updatedAt": null, "deletedAt": "2026-04-29T09:00:00.000Z" } ], "nextCursor": null, "serverTime": "2026-04-29T12:00:00.000Z" }}Store serverTime as your next since value. This ensures you never miss updates that arrive between your manifest fetch and your next sync.
GET /v1/kbs/:id/raw — fetch raw document
Fetches the raw Markdown content of one document by relative path. Response is text/markdown, not JSON.
| Query param | Notes |
|---|---|
path | POSIX relative path (URL-encoded). NFC-normalized server-side. |
curl "https://api.picora.me/v1/kbs/V1StGXR8_Z5jdHi6B-myT/raw?path=notes%2Fidea.md" \ -H "Authorization: Bearer sk_live_..." \ -vResponse headers include X-Source-Hash and X-Updated-At for version verification.
POST /v1/kbs/:id/sync — batch push
Pushes up to 100 operations (upsert + delete) in a single request. Each op is executed independently — partial success is a valid response.
Conflict detection algorithm
| Server state | Client op | baseUpdatedAt | Result |
|---|---|---|---|
| No record | upsert | any | ✅ apply |
| No record | delete | any | ⏭ skip |
| Soft-deleted | upsert | absent | ✅ apply (recreate) |
| Soft-deleted | upsert | before deletedAt | ⚠️ REMOTE_DELETED |
| Soft-deleted | upsert | after deletedAt | ✅ apply |
| Soft-deleted | delete | any | ⏭ skip |
| Active | upsert | absent | ⚠️ BASE_MISSING |
| Active | upsert | before updatedAt | ⚠️ REMOTE_NEWER |
| Active | upsert | ≥ updatedAt | ✅ apply |
| Active | delete | absent | ⚠️ BASE_MISSING |
| Active | delete | before updatedAt | ⚠️ REMOTE_NEWER |
| Active | delete | = updatedAt | ✅ apply |
Request body
{ "ops": [ { "op": "upsert", "relativePath": "notes/idea.md", "content": "# My Idea\n\nContent here...", "sourceHash": "a3f2b1c4d5e6f7a8b9c0d1e2f3a4b5c6d7e8f9a0b1c2d3e4f5a6b7c8d9e0f1a2", "baseUpdatedAt": "2026-04-29T08:00:00.000Z" }, { "op": "delete", "relativePath": "archive/old.md", "baseUpdatedAt": "2026-04-28T06:00:00.000Z" } ]}sourceHash: must equal SHA-256(content) hex — mismatch returns LOCAL_HASH_MISMATCH conflict.
baseUpdatedAt: omit for “create new” semantics. For updates, set to the updatedAt from your last manifest or previous sync response.
Response
{ "success": true, "data": { "applied": [ { "op": "upsert", "relativePath": "notes/idea.md", "id": "abc123XYZ_ki1234567890", "updatedAt": "2026-04-29T12:00:00.000Z", "sourceHash": "a3f2b1..." } ], "conflicts": [ { "op": "upsert", "relativePath": "shared/config.md", "reason": "REMOTE_NEWER", "remote": { "sourceHash": "x9y8z7...", "sizeBytes": 512, "updatedAt": "2026-04-29T11:00:00.000Z", "deletedAt": null } } ], "skipped": [], "serverTime": "2026-04-29T12:00:00.000Z" }}Conflict resolution: On REMOTE_NEWER, fetch the remote version via /raw, perform 3-way merge locally, then re-submit with the new baseUpdatedAt. On REMOTE_DELETED, confirm with the user whether to recreate.
Relative path rules
- POSIX format; no leading
/, no.., no.\, no empty segments (a//b) - Maximum path length: 1024 chars; each segment: 255 chars
- UTF-8 allowed (including CJK, emoji); auto NFC-normalized server-side
- Hidden files/dirs allowed (
.hidden,.moraya/index.json) - Backslash (
\) rejected — always use/
✅ notes/2026/04/idea.md✅ .moraya/index.json✅ 图片/封面.md✅ CLAUDE.md
❌ /absolute/path.md (absolute)❌ ../etc/passwd (parent traversal)❌ a//b.md (empty segment)❌ C:\notes\file.md (backslash)Related
- Markdown Documents API — individual document CRUD
- MCP tools — AI-workflow upload via MCP
upload_doc