Skip to content

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

FieldTypeNotes
idstring (nanoid 21)KB ID
namestringDisplay name (≤ 120 chars)
slugstringURL-safe identifier, immutable after creation. Auto-derived from name if omitted.
descriptionstring | nullOptional description (≤ 500 chars)
docCountintCount of active docs (cached)
sizeBytesintTotal bytes of active docs (cached)
isDefaultbooleanAt most one default KB per user
createdAt, updatedAtISO 8601

Resource model — KbManifestEntry

FieldTypeNotes
relativePathstringPOSIX relative path, NFC-normalized
sourceHashstring | nullSHA-256 hex (64 chars); null for soft-deleted entries
sizeBytesint | nullByte size; null for soft-deleted entries
updatedAtISO 8601 | nullLast modified; null for soft-deleted entries
deletedAtISO 8601 | nullSoft-delete timestamp; null for active docs

Quotas

PlanKB count
trial5
pro50
pro_plus500

Endpoints

POST /v1/kbs — create

Terminal window
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

ParamTypeNotes
cursorstringPagination cursor from nextCursor
limitint1–50, default 20
sortstringupdated_at (default) | name
Terminal window
curl https://api.picora.me/v1/kbs \
-H "Authorization: Bearer sk_live_..."

GET /v1/kbs/:id — detail

Terminal window
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.

Terminal window
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 paramNotes
cascade=falseDefault. Returns 409 KB_NOT_EMPTY if there are active docs
cascade=trueCascades soft-delete to all docs, cleans R2 storage, releases doc_count quota
Terminal window
# 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 cascade
curl -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 only
2. 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 changes

GET /v1/kbs/:id/manifest — sync manifest

Returns lightweight metadata (no content) for all docs in the KB.

ParamTypeNotes
sinceISO 8601Incremental mode: returns entries where updated_at > since OR deleted_at > since (includes soft-deleted). Omit for full sync.
cursorstringPagination cursor
limitint1–1000, default 200
Terminal window
# Full sync (first time)
curl "https://api.picora.me/v1/kbs/V1StGXR8_Z5jdHi6B-myT/manifest" \
-H "Authorization: Bearer sk_live_..."
# Incremental sync
curl "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 paramNotes
pathPOSIX relative path (URL-encoded). NFC-normalized server-side.
Terminal window
curl "https://api.picora.me/v1/kbs/V1StGXR8_Z5jdHi6B-myT/raw?path=notes%2Fidea.md" \
-H "Authorization: Bearer sk_live_..." \
-v

Response 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 stateClient opbaseUpdatedAtResult
No recordupsertany✅ apply
No recorddeleteany⏭ skip
Soft-deletedupsertabsent✅ apply (recreate)
Soft-deletedupsertbefore deletedAt⚠️ REMOTE_DELETED
Soft-deletedupsertafter deletedAt✅ apply
Soft-deleteddeleteany⏭ skip
Activeupsertabsent⚠️ BASE_MISSING
Activeupsertbefore updatedAt⚠️ REMOTE_NEWER
ActiveupsertupdatedAt✅ apply
Activedeleteabsent⚠️ BASE_MISSING
Activedeletebefore updatedAt⚠️ REMOTE_NEWER
Activedelete= 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)