Knowledge base
Projects are org-scoped collections of inter-connected markdown documents. Bodies live in object storage; [[slug]] wikilinks are resolved client-side.
A knowledge base is a set of inter-connected markdown documents, grouped
into projects. Each project belongs to one owner — your personal account,
or an organization — and every document in it is markdown you can link
together with Obsidian-style [[slug]] wikilinks.
The server stores document bodies in object storage (not Postgres), keeps only ownership and metadata in the database, and does not parse links or compute backlinks. Connectivity lives in the documents' own content and is rebuilt in the browser from the document list — matching how an Obsidian vault is a folder of notes, not a graph store.
Requires a Stytch session JWT
Every route on this page is under /me/* and authenticates with a Stytch
session JWT (Authorization: Bearer $JWT), held by the dashboard in a
cookie. A proxy key (sk-rc-…) or an admin token returns 401 — the same
rule as every other /me/* route. See API keys.
Owners and scope
Projects are org-scoped. "Personal" projects are simply the projects of your auto-provisioned personal organization; org projects live under the org they belong to. There is no per-user ownership column threaded through the API — exactly as invokers and upstreams work after the organizations migration.
- Personal —
/me/projects. You are always your personal org's admin, so no role check applies: read and write your own projects freely. - Organization —
/me/organizations/{id}/projects. Any member can list projects and documents and fetch a single one; only admins can create, update, or delete. A non-member and a nonexistent org both return404 organization not found— the response is identical on purpose, so it can't be used to probe which org IDs exist. See Organizations for roles and membership.
Projects
GET /me/projects
List your personal projects.
curl https://api.ada.ai/me/projects \
-H "Authorization: Bearer $JWT"{
"projects": [
{
"id": 42,
"organization_id": 7,
"name": "Research notes",
"description": "Context for ongoing experiments.",
"created_at": "2026-08-20T23:02:02Z",
"updated_at": "2026-08-21T08:46:10Z"
}
]
}POST /me/projects
Create a project. name is required; description is optional (defaults to
the empty string). Returns 201 Created with the new project.
curl -X POST https://api.ada.ai/me/projects \
-H "Authorization: Bearer $JWT" \
-H "Content-Type: application/json" \
-d '{"name": "Research notes", "description": "Context for ongoing experiments."}'A name that already exists for the same owner returns 409 name_taken. Names
are unique within an owner but free to repeat across owners.
GET /me/projects/{id} / PUT /me/projects/{id} / DELETE /me/projects/{id}
Fetch, partially update, or delete a single project. PUT takes {name, description} with both fields optional — omit a field to leave it unchanged.
DELETE returns 204 No Content and cascades to every document in the
project (their bodies are deleted best-effort from object storage).
curl -X PUT https://api.ada.ai/me/projects/42 \
-H "Authorization: Bearer $JWT" \
-H "Content-Type: application/json" \
-d '{"description": "Updated scope."}'A {id} that doesn't exist or belongs to another owner returns 404 not found — ownership checks resolve to "no row," so a foreign id is
indistinguishable from a nonexistent one (same as invokers and upstreams).
Documents
Every document has a title, a slug, and a markdown body. The slug
is the stable, URL-safe link key that [[slug]] in any document's content
resolves to, and the body is the markdown text itself.
List endpoints return metadata only — id, project_id, title,
slug, timestamps. The markdown body is fetched separately and returned only
by the single-document GET. The internal object pointer is never
serialized.
GET /me/projects/{id}/documents
List the documents in a project (metadata only).
curl https://api.ada.ai/me/projects/42/documents \
-H "Authorization: Bearer $JWT"{
"documents": [
{
"id": 100,
"project_id": 42,
"title": "Welcome",
"slug": "welcome",
"created_at": "2026-08-20T23:10:00Z",
"updated_at": "2026-08-21T08:46:10Z"
}
]
}POST /me/projects/{id}/documents
Create a document. title and slug are required; content is optional
(defaults to the empty string, which stores an empty note). Returns 201 Created with the new document including its content.
curl -X POST https://api.ada.ai/me/projects/42/documents \
-H "Authorization: Bearer $JWT" \
-H "Content-Type: application/json" \
-d '{
"title": "Welcome",
"slug": "welcome",
"content": "# Welcome\n\nSee [[getting-started]] to begin."
}'A slug that already exists within the same project returns 409 slug_taken.
Slugs are unique within a project but free to repeat across projects — a
[[welcome]] link only ever resolves within the project that contains it.
GET /me/projects/{id}/documents/{document_id}
Fetch a single document with its content.
curl https://api.ada.ai/me/projects/42/documents/100 \
-H "Authorization: Bearer $JWT"{
"id": 100,
"project_id": 42,
"title": "Welcome",
"slug": "welcome",
"created_at": "2026-08-20T23:10:00Z",
"updated_at": "2026-08-21T08:46:10Z",
"content": "# Welcome\n\nSee [[getting-started]] to begin."
}PUT /me/projects/{id}/documents/{document_id}
Partially update a document. title, slug, and content are all optional —
omit a field to leave it unchanged. Returns 200 OK with the full document
(including content).
curl -X PUT https://api.ada.ai/me/projects/42/documents/100 \
-H "Authorization: Bearer $JWT" \
-H "Content-Type: application/json" \
-d '{"content": "# Welcome\n\nUpdated. See [[getting-started]]."}'Renaming a document only rewrites metadata
The body is stored under a key derived from the document's immutable
id, not its slug. Changing slug is a pure metadata update — the body is
never copied or deleted — and is the only thing that changes how
[[old-slug]] links resolve across the rest of the project. Sending a
non-nil content replaces the body at its existing key.
DELETE /me/projects/{id}/documents/{document_id}
Delete a document. Returns 204 No Content; the body is removed from object
storage best-effort.
Wikilinks
A [[slug]] token embedded in any document's content links to the document
with that slug within the same project. The server treats content as
opaque text: it does not parse [[…]], does not validate that a target
exists, and does not compute backlinks. A dangling [[slug]] (one whose
target was never created, renamed, or deleted) is a client rendering
concern, not a write-time error — link resolution and any "missing link"
treatment happen entirely in the browser from the document list.
Because slugs are unique only within a project, the same slug can appear in different projects and resolve independently. There is no cross-project linking.
Organization projects
The same surface is available for an organization at
/me/organizations/{id}/projects. Replace the personal base with the org
base and use {project_id} for the project path segment:
| Method | Path |
|---|---|
GET | /me/organizations/{id}/projects |
POST | /me/organizations/{id}/projects |
GET / PUT / DELETE | /me/organizations/{id}/projects/{project_id} |
GET / POST | /me/organizations/{id}/projects/{project_id}/documents |
GET / PUT / DELETE | /me/organizations/{id}/projects/{project_id}/documents/{document_id} |
The request bodies, response shapes, validation, and conflict codes are identical to the personal endpoints above. The only differences are access control:
- Any member can list projects/documents and fetch a single one.
- Only admins can create, update, or delete projects and documents; a
non-admin gets
403 only admins can …. - A non-member (or a nonexistent org) gets
404 organization not found, identical to every other org-scoped route.
curl -X POST https://api.ada.ai/me/organizations/7/projects \
-H "Authorization: Bearer $JWT" \
-H "Content-Type: application/json" \
-d '{"name": "Team knowledge base"}'Validation
| Field | Rule | Error type |
|---|---|---|
Project name | Required (trimmed). | invalid_name |
Document title | Required (trimmed). | invalid_title |
Document slug | Required. 1–80 lowercase alphanumeric or -, and cannot start or end with -. | invalid_slug |
description | Optional. Defaults to "". | — |
content | Optional. Defaults to "". | — |
A malformed JSON body returns 400 invalid JSON: …. The entire request body
is capped at 1 MB, so title + slug + content together must fit — a
larger body fails to decode.
Errors
| Status | Meaning | type |
|---|---|---|
400 | Bad path id, invalid JSON, or a field failed validation. | invalid_name / invalid_title / invalid_slug |
401 | Missing or invalid Stytch session JWT (a proxy key is not accepted here). | — |
403 | An org mutation attempted by a non-admin. | — |
404 | Project or document not found, or you don't own it. On org routes, organization not found also covers non-members. | — |
409 | Name or slug already exists for the owner. | name_taken / slug_taken |
500 | Unexpected store or object-storage failure. | — |
Limits (v1)
- Documents are plain markdown. The server does not parse links, compute
backlinks, or validate
[[slug]]targets — all of that is client-side. - Slugs are unique within a project and may repeat across projects. There is no cross-project linking.
- Request bodies are capped at 1 MB. There is no separate streaming upload for document content yet.
- There is no search, versioning, or attachments in v1 — a document is one markdown string under a slug.
Organizations
Organizations let a team share one pool of upstreams under a single account. Each proxy key is scoped to exactly one owner — you, or one organization.
CLI Authentication
The browser-assisted device flow lets a CLI tool mint a proxy key without ever handling the user's session credentials.