Everything the dashboard can do is a JSON REST call — the dashboard is just a client of this API. Use it for scripted setup, scheduled re-indexing, or monitoring.
Base URL http://localhost:3444/api
Authentication
Every request carries one of three credentials:
| What it is | Who uses it | |
|---|---|---|
| A session cookie | contextator_session, set by POST /api/auth/login |
The dashboard, and anything driving a browser |
Authorization: Bearer ctxk_… |
An account’s own API token — named, scoped to a subset of routes and, optionally, one project, revocable on its own | Scripts and CI jobs that should carry only some of an account’s authority — see API tokens below |
Authorization: Bearer $ADMIN_TOKEN |
Machine access with root permissions |
Scripts, CI, cron that need the whole instance |
export API=http://localhost:3444/api
export TOKEN=your-admin-token
curl -s $API/projects -H "Authorization: Bearer $TOKEN" | jq
Five things follow from that split:
- What you may do depends on who you are. A session is bound to an account, so its instance role
and its per-project memberships decide every call — see
Accounts and Permissions.
ADMIN_TOKENalways acts asroot. - An API token can never do more than the account that minted it. Its scope is checked after that account’s own role and membership, so narrowing it — to one project, or to a handful of routes — can only take authority away, never add it.
- A cookie request that changes something must come from this site. The server checks
Sec-Fetch-Site, falling back toOriginandReferer, and answers403 csrf_blockedotherwise. Bearer requests are exempt: they carry no ambient credential, which is whycurland CI — sending neither header — work unchanged. - Neither bearer token counts as a signed-in account, as far as these routes are concerned. The
endpoints that are about your account (
/api/auth/password,/api/auth/sessions) require a session — that check refusesADMIN_TOKENand a scoped API token alike with403 token_has_no_account, even a token scoped to allow the exact route. Only a browser holding a session cookie can reach them. - A project you cannot reach answers
404, not403, the same as a project that does not exist, so ids cannot be probed. A route you may reach but not use in that way answers403.
GET /api/health, GET /api/setup/status, POST /api/setup, POST /api/auth/login,
GET /api/auth/oidc/login and GET /api/auth/oidc/callback are the only endpoints reachable with no
credential at all — the last two because the browser has no session yet when it asks for the redirect,
and the provider sends the browser back to the callback with the code, by redirect — not a
server-to-server call from the provider itself. Linking an already signed-in account to that same
provider (POST /api/auth/oidc/link, DELETE /api/auth/oidc/link — see
Single Sign-On) is different: it needs the
caller’s own session, same as any other self-service account endpoint.
Health
curl -s $API/health | jq
{
"ok": true,
"version": "0.2.0",
"uptimeSec": 3610,
"db": "up",
"embeddings": {
"id": "local:Xenova/multilingual-e5-small:fp32:\"query: \"+\"passage: \"",
"provider": "local", "model": "Xenova/multilingual-e5-small",
"dimensions": 384, "dtype": "fp32", "ready": true
},
"sessions": { "total": 2, "streamable": 2, "sse": 0 },
"allowedDocRoots": ["/docs"],
"dataDir": "/data",
"secretKeyConfigured": true,
"uploads": { "maxFileBytes": 52428800, "maxFilesPerRequest": 500, "maxArchiveBytes": 268435456 }
}
Good liveness probe: ok === true && db === "up". Good readiness probe: also embeddings.ready. The
response answers 503 with the same body while the database is unreachable, 200 otherwise — a monitor
does not need a second check for that.
embeddings also carries the model’s input window and whether CHUNK_MAX_TOKENS fits inside it, so a
misconfiguration that would silently truncate chunks shows up here before it shows up in search quality —
see Configuration.
An anonymous caller gets a shorter answer — ok, version, db, authRequired and needsSetup —
because the rest describes the machine. The fields a monitor watches are in both shapes.
Setup and sign-in
| Method & path | Description |
|---|---|
GET /api/setup/status |
{ needsSetup } — true while this instance has no account. Public |
POST /api/setup |
{ code, username, displayName?, email?, password } → 201 { user }, and the session cookie. Creates the first root account. 403 invalid_setup_code, 409 once an account exists. Public |
POST /api/auth/login |
{ username, password } → { user, mustChangePassword } and the cookie. 401 invalid_credentials for both a wrong password and an unknown username, 403 account_disabled, 429 with Retry-After when rate-limited. Public |
POST /api/auth/logout |
Revokes this session and clears the cookie → 204. Idempotent |
GET /api/auth/me |
The signed-in account, its role, authKind (session|token|apiToken — a dashboard session, ADMIN_TOKEN, or an API token), and — for a member — its per-project roles in projects. Also oidc ({ enabled, buttonLabel }), canLinkOidc (false for root) and federatedProviders — see Single sign-on below |
POST /api/auth/password |
{ currentPassword, newPassword } → 204. Clears mustChangePassword and revokes this account’s other sessions |
GET /api/auth/sessions |
Your own live sessions: when each started, when it was last seen, its user agent and IP, and which one is current |
DELETE /api/auth/sessions?scope=others|all |
End them → 204. others is the default; all signs you out too |
# sign in and keep the cookie in a jar, then use it like a browser would
curl -s -c jar -X POST $API/auth/login -H 'content-type: application/json' \
-d '{"username":"alice","password":"…"}' | jq
curl -s -b jar $API/auth/me | jq
Single sign-on
Only present when this instance has an OIDC provider configured — see Single Sign-On for who can link and why root cannot.
| Method & path | Description |
|---|---|
GET /api/auth/oidc/login |
Redirects to the identity provider to sign in. Public — the browser has no session yet |
GET /api/auth/oidc/callback |
The identity provider sends the browser back here with the code, by redirect — not a server-to-server call from the provider. Public because a sign-in arrives with no session yet; a link arrives with the session that started it, carried by the browser’s own cookie on the redirect. Refuses to sign a root account in (root_local_only), even one whose link was made before a promotion to root — the role is re-read off the account at callback time, not assumed from when the link was made |
POST /api/auth/oidc/link |
Self-service, for an already signed-in account: starts the same redirect, but to connect the provider to this account rather than sign in fresh. A POST, so the same-site check applies. → 200 { url } to navigate to. Refused for root (403 root_local_only) — root never signs in over SSO |
DELETE /api/auth/oidc/link |
Removes the caller’s own linked identity → 204. Scoped to your own account; there is no admin surface over another account’s link. Not refused for root — a link made before a promotion to root stays in place until removed, and this is how. Also revokes every session and API token the account holds, including the one making this call, and — in the same transaction — every MCP OAuth access and refresh token the account was issued, so an MCP client signed in as it gets 401 until it authorizes again; other accounts are untouched. The unlink’s audit event records the count as detail.revokedMcpCredentials. 409 last_sign_in_method — nothing removed, nothing revoked — when the account has no local password and this link is its only way to sign in; an admin sets a password first with POST /api/users/:id/password, and the unlink then succeeds |
Accounts
/api/users/* needs admin or root. Only a root account may create, change or delete another
root account. PATCH /api/users/:id refuses 409 root_requires_unlink when asked to set
role: "root" on an account that still has a linked SSO identity — unlink it first with
DELETE /api/auth/oidc/link above.
| Method & path | Description |
|---|---|
GET /api/users |
Every account: role, active flag, mustChangePassword, project count, last sign-in, and activeSessionCount |
POST /api/users |
{ username, displayName?, email?, role?, password?, mustChangePassword? } → 201 { user, temporaryPassword }. role defaults to member and mustChangePassword to true; omit password and one is generated and returned once |
GET /api/users/:id |
{ user, activeSessionCount } |
PATCH /api/users/:id |
{ displayName?, email?, role?, isActive? } → the updated account. A demotion or a disable also ends that account’s sessions |
POST /api/users/:id/password |
{ password? } → { temporaryPassword }. Forces a change at the next sign-in and ends that account’s sessions |
DELETE /api/users/:id |
Delete the account, its sessions and its memberships → 204 |
DELETE /api/users/:id/sessions |
Sign that account out everywhere → 204 |
409 guards the last active root account against deletion, demotion and being disabled; 403 guards
an admin reaching for a root one, and anyone reaching for their own role or active flag.
# hand someone a new temporary password without touching the dashboard
ID=$(curl -s $API/users -H "Authorization: Bearer $TOKEN" | jq -r '.[] | select(.username=="alice") | .id')
curl -s -X POST $API/users/$ID/password -H "Authorization: Bearer $TOKEN" \
-H 'content-type: application/json' -d '{}' | jq -r .temporaryPassword
API tokens
/api/tokens* needs only a session — any account manages its own tokens, and there is no admin
view over anyone else’s. This is how a script or a CI job gets a credential narrower than
ADMIN_TOKEN: name it, optionally pin it to one project and an expiry date, and list exactly which
routes it may call. The dashboard’s own Account menu → API tokens page is a client of exactly
these endpoints — mint, list and revoke there without touching curl at all.
| Method & path | Description |
|---|---|
GET /api/tokens |
Your own tokens: id, name, prefix, scope, projectId, createdAt, lastUsedAt, revokedAt, expiresAt. Never the secret |
POST /api/tokens |
{ name?, scope, projectId?, expiresAt? } → 201 { token, secret }. scope is one or more "<METHOD> <route template>" strings, such as "POST /api/projects/:id/reindex". Mint only checks that shape and, if given, that expiresAt is in the future — it does not check an entry against your own role, nor projectId against your own projects. What keeps the token bounded is that every request re-checks your live role and project access at call time. secret is returned this once and never again. 401 session_revoked if your session was revoked before the mint could run |
DELETE /api/tokens/:tokenId |
Revoke one of your own tokens → 204. Takes effect on the very next request |
# mint a token that can only reindex one project — /api/tokens needs a session, not
# ADMIN_TOKEN, so sign in first (see "Setup and sign-in" above) and reuse the cookie jar.
# a cookie-authenticated request that changes something needs Sec-Fetch-Site too — see the
# CSRF rule above — curl never sends it on its own, unlike a real page's fetch would
curl -s -X POST $API/tokens -b jar \
-H 'content-type: application/json' -H 'Sec-Fetch-Site: same-origin' \
-d '{"name":"ci-reindex","scope":["POST /api/projects/:id/reindex"],"projectId":"'"$P"'"}' | jq
Deleting the project named in projectId deletes the token with it, rather than widening its reach.
Project members
Who reaches one project. root, admin and ADMIN_TOKEN reach every project without a membership,
so only member accounts appear here.
| Method & path | Description |
|---|---|
GET /api/projects/:id/members |
The project’s members and their roles. Any member of the project may read it |
PUT /api/projects/:id/members/:userId |
{ "role": "viewer" | "editor" } → the member. Creates or changes the membership (root/admin) |
DELETE /api/projects/:id/members/:userId |
Revoke access → 204 (root/admin) |
Adding a member to a project a member account can then see:
curl -s -X PUT $API/projects/$P/members/$ID -H "Authorization: Bearer $TOKEN" \
-H 'content-type: application/json' -d '{"role":"editor"}' | jq
400 if the target is an admin or root account — they already have access, so a membership row
would say something untrue.
Projects
| Method & path | Description |
|---|---|
GET /api/projects |
All projects with counts, mcpUrl and the live indexing job |
POST /api/projects |
{ "name": "demo", "rootPath": "/docs/demo", "index": true } → 201. rootPath is optional and creates a first local source |
GET /api/projects/:id/status |
One project plus its job |
POST /api/projects/:id/reindex?force=true |
Queue a run → 202 { job } |
GET /api/projects/:id/runs |
Recent index runs, newest first |
GET /api/projects/:id/search?q=…&limit=…&source=…&path_prefix=…&version=… |
The same search the project’s search_docs tool runs, as JSON: { query, limit, source, pathPrefix, version, belowFloor, scoreFloor, hits: [{ score, fusedScore, denseRank, lexicalRank, path, title, headingPath, chunkIndex, content, contextBefore, contextAfter }] }. score is the cosine similarity and is shown rather than ranked on; fusedScore is what ordered the list, and the two ranks say which half of search found the excerpt (null for the half that did not). belowFloor is whether an agent would have been told no good match — the hits come back either way, so the dashboard can show what was withheld. limit is 1–20 (default 5); source, path_prefix and version are optional. 400 invalid_request for a source or a version this project does not have (the message names the ones it does), 409 not_indexed when the project has no chunks, 409 model_mismatch when they were embedded with another model |
DELETE /api/projects/:id |
Delete the project → 204 (409 while indexing) |
# create and index
curl -s -X POST $API/projects -H "Authorization: Bearer $TOKEN" \
-H 'content-type: application/json' \
-d '{"name":"handbook","rootPath":"/docs/handbook","index":true}' | jq
# re-index everything, every night
for id in $(curl -s $API/projects -H "Authorization: Bearer $TOKEN" | jq -r '.[].id'); do
curl -s -X POST "$API/projects/$id/reindex" -H "Authorization: Bearer $TOKEN" > /dev/null
done
Watching a job
GET /api/projects includes a job for any project with a live run:
{ "phase": "embedding", "filesTotal": 84, "filesDone": 31, "filesSkipped": 22,
"filesRemoved": 0, "chunksDone": 96,
"sources": [{ "name": "handbook", "type": "local", "status": "synced" }],
"queue": null }
phase is one of queued, syncing, scanning, embedding, finalizing, done, error. A queued
job carries queue.aheadProjectName.
MCP access
| Method & path | Description |
|---|---|
GET /api/projects/:id/mcp-tokens |
The project’s live tokens: id, name, prefix, createdAt, lastUsedAt. The secret itself is never returned |
POST /api/projects/:id/mcp-tokens |
{ "name": "claude-code" } → 201 { token, secret }. secret is the only time the token is readable |
DELETE /api/projects/:id/mcp-tokens/:tokenId |
Revoke it → 204, closing the project’s open MCP sessions |
PATCH /api/projects/:id/mcp-auth |
{ "mode": "open" | "token" | "account" } → { "mcpAuth" }. Switching away from open also closes the project’s open sessions. account requires the caller to be a member of the project through OAuth 2.1 on /mcp/*; a static ctxm_… token is refused in that mode |
GET /api/projects reports the current mode as mcpAuth. Closing an endpoint and minting a token:
curl -s -X PATCH $API/projects/$P/mcp-auth -H "Authorization: Bearer $TOKEN" \
-H 'content-type: application/json' -d '{"mode":"token"}' | jq
curl -s -X POST $API/projects/$P/mcp-tokens -H "Authorization: Bearer $TOKEN" \
-H 'content-type: application/json' -d '{"name":"claude-code"}' | jq -r .secret
The client then sends that secret as Authorization: Bearer ctxm_… on /mcp/<project-name> — see
Connecting AI Clients.
Sources
| Method & path | Description |
|---|---|
GET /api/projects/:id/sources |
The project’s sources. Secrets are never returned — only hasSecret |
POST /api/projects/:id/sources { type, name, label?, flavor?, config?, secret?, syncIntervalMinutes?, index? } |
Add a source. type is local, git, upload, notion, confluence or web; config is type-specific — see the table below. secret is taken only by the types that use a credential (git, notion, confluence); on local, upload or web it answers 400 invalid_request naming the type, and secret: null (meaning “no secret”) is accepted on every type. syncIntervalMinutes is 5–43200 or null; omitted takes the instance default |
PATCH /api/projects/:id/sources/:sid |
Change label, content type, config or token ("secret": null removes it). Type and name are immutable |
DELETE /api/projects/:id/sources/:sid |
Remove it, its documents and its files (409 while indexing) |
POST /api/projects/:id/sources/:sid/sync |
Queue a run → 202 { job } |
POST /api/projects/:id/sources/:sid/test |
Connectivity check → { ok, message } |
POST /api/projects/:id/sources/:sid/webhook-secret |
Generate a new webhook secret: rotates it on a git source, and on a Confluence source turns the webhook on (or regenerates it) — see Push Webhooks |
DELETE /api/projects/:id/sources/:sid/webhook-secret |
Confluence only: turn the webhook off. Deliveries are then refused with not_enabled again |
Adding a git source:
curl -s -X POST $API/projects/$PROJECT_ID/sources \
-H "Authorization: Bearer $TOKEN" -H 'content-type: application/json' \
-d '{
"type": "git",
"name": "api-docs",
"label": "API reference",
"config": { "url": "https://github.com/org/repo.git", "branch": "main", "subdir": "docs" },
"secret": "ghp_…",
"index": true
}' | jq
config by type:
| Type | Keys |
|---|---|
local |
path, extensions |
git |
url, branch, subdir, username, provider, extensions |
upload |
extensions |
notion |
rootIds, extensions |
confluence |
deployment (cloud | datacenter), siteUrl, accountEmail (Cloud only), spaces — see Confluence |
web |
entryUrl, entryKind (auto | sitemap | llms | crawl, default auto), extensions — public pages only, no credential |
Uploads
| Method & path | Description |
|---|---|
POST /api/projects/:id/sources/:sid/uploads |
Open a session → { session } |
POST …/uploads/:session/files |
multipart/form-data; each part’s filename is the path inside the source. Archives are unpacked server-side |
POST …/uploads/:session/commit?mode=add|replace |
Move the staged files in and queue a run → 202 |
DELETE …/uploads/:session |
Discard the staged upload |
GET /api/projects/:id/sources/:sid/files |
List an upload source’s files |
DELETE /api/projects/:id/sources/:sid/files?path=… |
Delete one and re-index |
SESSION=$(curl -s -X POST $API/projects/$P/sources/$S/uploads -H "Authorization: Bearer $TOKEN" | jq -r .session)
curl -s -X POST "$API/projects/$P/sources/$S/uploads/$SESSION/files" \
-H "Authorization: Bearer $TOKEN" -F '[email protected];filename=guides/guide.md'
curl -s -X POST "$API/projects/$P/sources/$S/uploads/$SESSION/commit?mode=add" \
-H "Authorization: Bearer $TOKEN"
Webhooks
| Method & path | Description |
|---|---|
POST /api/webhooks/git/:sourceId |
Push webhook for a git source, authenticated by the per-source secret — not by an account or ADMIN_TOKEN |
POST /api/webhooks/confluence/:sourceId |
Confluence Data Center webhook, signed with X-Hub-Signature over the per-source secret. 401 not_enabled until the source’s webhook is turned on |
See Push Webhooks.
Metrics
curl -s http://localhost:3444/metrics -H "Authorization: Bearer $METRICS_TOKEN"
GET /metrics is a separate, Prometheus-shaped endpoint (text/plain; version=0.0.4) with its own
credential rules — it is not part of /api/* and is not public by default. It reports the
indexing queue by lane, whether one is running, the last index run and whether it worked, searches by
actor, the database connection pool, and whether the database itself answers.
It accepts a signed-in session, ADMIN_TOKEN, a METRICS_TOKEN bearer, or no credential at all when
METRICS_PUBLIC=1 — see Configuration. It answers 200 even
while the database is down, with contextator_db_up 0 and the rows that need a database left out, so a
scrape gap is never how an outage gets reported. While the database is down only the three credentials
that need no database read still work — ADMIN_TOKEN, METRICS_TOKEN, METRICS_PUBLIC — a session
cookie is a database row and cannot be confirmed, so a browser presenting one gets 401 rather than
500 during an outage. That is the reason to configure METRICS_TOKEN before an outage, not during one.
Audit log
/api/audit needs root or admin — the log is instance-wide, and a project membership is not
standing to read who was given the root role.
| Method & path | Description |
|---|---|
GET /api/audit?actor&actorUser&action&project&from&to&limit&cursor |
The audit log, newest first. Every filter is applied in SQL: actor is the label as it was recorded at the time; actorUser is an account id matched exactly against the acting account — it returns that account’s own sessions and every API token it owns, whatever the tokens are named; a value that is not a UUID answers 400 validation_failed. action is <METHOD> <route template>; project is a project id or none. from/to are UTC days, and to is inclusive of the named day. limit is 1–200 (default 50). cursor is the previous page’s nextCursor — a row id, never an encoded instant, so two events inside the same millisecond cannot lose one of themselves at a page boundary; a cursor naming no row answers 400. Answers { events, nextCursor, filters, retentionDays } — nextCursor is null on the last page, and filters (distinct actors, actions, projects, and the accounts with events, for the pickers) comes back on the first page only |
curl -s "$API/audit?action=DELETE%20/api/projects/:id&limit=50" \
-H "Authorization: Bearer $TOKEN" | jq
How it is written
Every state-changing request above leaves a row in audit_events naming the account that made it — what
was done, to which project, source, token, membership or account, and when. A sign-in and the creation
of the first account are recorded too, and so is POST /oauth/authorize, a person granting a connector
lasting read access to one project.
It is written by the policy layer, not by each handler, so there is no route that can be added
without being covered — and none that can opt out. The exceptions are eight — a source’s test and
upload-session routes, the three webhook deliveries (git, Notion, Confluence) and the three OAuth
client-registration routes (register, token, revoke) — each named with its reason in
src/auth/policy.ts.
A route that creates something names nothing in its own path, so the new object’s id is read back out of the response, through a table of fixed paths in that same file, and kept only when the value found there is a UUID — “who minted this token” is therefore a question the log answers, and it lines up against the revocation that names the same id.
What it does not hold
The rows carry no user content: a question, a document and an excerpt never reach a column of this
table. The only body fields any action may record are named in that same policy file, each restricted to
a closed set of values (mode is one of open/token/account, and so on). That keeps it a different
record from the query log, which holds what agents asked, is governed by its own retention, and has its
own per-project switch — the two are deliberately not one table.
Two things beyond that are deliberately not recorded. Refused requests — a refusal is the permission
matrix working, and logging every probe would turn the table into a scan log; the one exception is a
person refusing a connector at /oauth/authorize, which is somebody deciding rather than the matrix
declining. And an action that changed something and then answered 5xx: the row is written only for
a response under 400, so a handler that commits and then fails afterwards leaves none. No handler in this
API is currently shaped that way, and SECURITY.md names it as the limit it is.
Reading it
GET /api/audit and the dashboard’s Audit log panel (account menu, beside Users) are both root/admin
only. Each row renders as a sentence (“dana deleted a source from handbook”) rather than as the columns
it is stored in. “Who” is two pickers: Actor is the label as it was recorded, and Account is the
account id — which finds that account’s own actions together with those of every API token it owns, since
a token’s label is only its name and owner and cannot gather them on its own. A project that has since
been deleted still has its rows: project_id carries no foreign key, so the panel cannot look its name
up, and says so on the row rather than leaving it blank.
The two columns the panel filters on — actor_label, because it outlives the account, and action — are
indexed by migration 0011. At 200,004 rows a selective actor filter runs in 0.07 ms against 9.7 ms
without the index, a page turn is 3 ms, and a first page is 19–52 ms because it also fills the filter
dropdowns with three DISTINCT scans no index can help — paid once per filter change, never per page
turn.
The log is not tamper-evident — anyone with database access can remove a row and nothing here would
show it (SECURITY.md) — and rows older than AUDIT_LOG_RETENTION_DAYS are swept on the same
quarter-hourly timer as expired sessions.
Errors
| Status | Meaning |
|---|---|
400 |
Validation failed, or an invalid request (bad path, missing SECRET_KEY, a password the policy refuses) — message explains |
401 |
No credential, or a wrong one. setup_required means this instance has no account yet |
403 |
Signed in, but not allowed: forbidden, csrf_blocked, password_change_required, account_disabled, token_has_no_account |
404 |
Unknown project, source or session — and any project this account has no access to |
409 |
Duplicate name, the project is indexing, setup is already complete, or the last root account. DELETE /api/auth/oidc/link answers 409 last_sign_in_method instead when the account has no local password — unlinking would leave it no way to sign in, so nothing is removed until an admin sets one |
429 |
Too many sign-in attempts. Retry-After and retryAfterSec say how long |
500 |
Internal error — the response says only that; the details are in the logs |
All errors are JSON: { "error": "conflict", "message": "…" }.
Notes
- Secrets are never returned by any endpoint; a source only reports
hasSecret, and a password is never returned at all except the one time a temporary one is generated. POST /api/projects/:id/reindexis safe to call often: indexing is incremental.- Authorization is decided server-side from one policy table covering routes by method and shape, so a new endpoint is protected the moment it exists. What the dashboard hides is cosmetic.
- The API is bound to the same port as the dashboard and the MCP endpoints, so anything that can reach one can reach the others (Security).