Skip to content

HTTP API

HTTP API surface — REST + SSE under /v1, designed AI-first.

Status: Draft v0.1 · 2026-05-03 Base URL: http://<host>:3000/v1 Auth: Authorization: Bearer <token> Spec: OpenAPI 3.1 served at GET /openapi.json AI runtime: see AI.md for the full surface; this document defines its HTTP transport.


The API is the canonical surface; the CLI and MCP server are adapters over it. So the API’s design constraints are stricter than either:

  1. JSON in, JSON out. No multipart, no form-encoded bodies (except attachment uploads).
  2. Resource-oriented. Nouns in URLs, verbs in methods.
  3. One envelope. Every response is { ok, data | error }. Errors follow RFC 7807 Problem Details shape inside error.
  4. Optimistic concurrency. ETag on every read, If-Match honored on every write.
  5. Idempotency. Idempotency-Key header accepted on every write; replays return the original response.
  6. Cursors, not offsets. All list/search endpoints paginate via opaque cursor.
  7. Streaming for long ops. SSE for change feeds, NDJSON for large result sets, both negotiated via Accept.
  8. Schema-first. Zod → JSON Schema → OpenAPI 3.1, served live from the running server.
Authorization: Bearer indx_<32 hex chars>

A token has zero or more scopes:

ScopeAllows
vault:readAll GET on /v1/notes/*, /v1/links/*, /v1/tags, /v1/search, /v1/canvas/*, /v1/bases/*, /v1/vault/status, /v1/events
vault:writePUT / PATCH / DELETE / POST on writable resources
vault:adminPOST /v1/vault/reindex, GET/PUT /v1/vault/config, token management
path:<glob>Restrict any of the above to paths matching <glob>

Missing token → 401. Wrong scope → 403. Both follow the error envelope (§4).

HeaderDirectionPurpose
AuthorizationrequestBearer token (required)
Idempotency-KeyrequestReplay-safe writes (UUID v4 recommended)
If-MatchrequestOptimistic concurrency on writes
If-None-MatchrequestConditional GET; 304 if unchanged
Acceptrequestapplication/json (default), application/x-ndjson, text/event-stream
ETagresponseStrong ETag = first 16 chars of xxhash64 of content
Last-Modifiedresponsemtime
X-Indx-ActorresponseThe actor recorded for the change
X-Indx-Indexed-AtresponseIndex timestamp
{
"ok": false,
"error": {
"type": "https://indx.dev/errors/conflict",
"code": "etag_mismatch",
"title": "ETag mismatch",
"status": 409,
"detail": "Note Home.md has changed since you read it.",
"instance": "/v1/notes/Home.md",
"current_etag": "ab12cd34…",
"received_etag": "00000000…"
}
}

Every error has a stable machine code (code) and an HTTP status (status). The code is the contract; the title/detail strings can change.

HTTPcodeWhen
400bad_requestMalformed JSON, missing required field
400validation_failedZod validation error; details.errors array
401unauthorizedNo token / invalid token
403forbiddenToken lacks scope
404not_foundNo such resource
409etag_mismatchIf-Match failed
409already_existsCreate with conflicting path
410gonePath exists in tombstone
415unsupported_media_typeBad Content-Type
422parse_failedMarkdown/canvas/base parse error
423readonlyINDX_READONLY=true
429rate_limitedToo many requests for this token
500internalUnexpected — please file a bug
503reindexingFirst-boot reindex still in progress
422ai_grounding_failedAll model citations failed verification under cite: true (AI.md §6)
422ai_input_too_largeResolved AI scope exceeds INDX_AI_MAX_INPUT_TOKENS
429ai_quota_exceededINDX_AI_DAILY_COST_USD reached
502ai_provider_errorUpstream chat/embeddings call failed
503ai_unavailableNo suitable AI provider configured for this op
422ai_schema_invalidai_extract.schema is not a valid JSON Schema
409ai_apply_conflictOne or more per-path if_match checks failed under apply: true
PathResourceMethods
/v1/healthhealth probeGET
/v1/notes/{path}a markdown noteGET, PUT, PATCH, DELETE
/v1/notes/{path}/moverenamePOST
/v1/noteslistingGET
/v1/searchsearchGET
/v1/links/{path}/backlinksinbound linksGET
/v1/links/{path}/forwardoutbound linksGET
/v1/links/unresolvedbroken linksGET
/v1/links/orphansorphan notesGET
/v1/tagsall tagsGET
/v1/tags/{tag}/notesnotes for a tagGET
/v1/tags/renamebulk renamePOST
/v1/canvas/{path}a JSON CanvasGET, PUT, PATCH
/v1/bases/{path}a .base fileGET, PUT
/v1/bases/{path}/queryrun the baseGET
/v1/vault/statushealth/countsGET
/v1/vault/reindextrigger reindexPOST
/v1/vault/configruntime configGET, PUT
/v1/eventslive changesGET (SSE)
/v1/ai/statusAI runtime statusGET
/v1/ai/summarizesummarize a scopePOST
/v1/ai/askgrounded Q&A over the vaultPOST
/v1/ai/tocTOC (single note) or MOC (folder/glob/tag)POST
/v1/ai/relaterelated notes + relation typingPOST
/v1/ai/tagtag suggestion / application (frontmatter tags:)POST
/v1/ai/metadatatyped frontmatter generation / populationPOST
/v1/ai/extractstructured entity extraction into a JSON SchemaPOST
/openapi.jsonspecGET
/mcpMCP HTTP transportPOST/GET

/v1/ai/* endpoints are advertised in /openapi.json iff a provider is configured (AI.md §3). Without a provider, calls return 503 ai_unavailable and the OpenAPI document omits them.

Resource paths are vault-relative POSIX paths, percent-encoded once. Projects/Foo Bar.md becomes Projects/Foo%20Bar.md. The server normalizes (no .., no leading /).

Query:

  • include — comma-separated: outline, links, tags, frontmatter (default: all). Use include= to get only body + metadata for cheaper reads.
GET /v1/notes/Home.md?include=outline,frontmatter
{
"ok": true,
"data": {
"path": "Home.md",
"kind": "md",
"etag": "ab12cd34ef567890",
"mtime_ms": 1746230400000,
"size": 1234,
"frontmatter": { "title": "Home", "tags": ["index"] },
"outline": [
{ "level": 1, "text": "Home", "line": 0, "block_id": null },
{ "level": 2, "text": "Today", "line": 4, "block_id": null }
],
"body": "# Home\n\n## Today\n\n…"
}
}

6.2 PUT /v1/notes/{path} — create or replace

Section titled “6.2 PUT /v1/notes/{path} — create or replace”
PUT /v1/notes/Home.md
Content-Type: application/json
If-Match: ab12cd34ef567890 ← omit on create
Idempotency-Key: 5e3c…
{ "frontmatter": { }, "body": "…" }
  • frontmatter and body are merged into a single file. body may include its own ----fenced frontmatter if frontmatter is omitted.
  • Returns the full resource (read-after-write) with the new ETag.

6.3 PATCH /v1/notes/{path} — structured edit

Section titled “6.3 PATCH /v1/notes/{path} — structured edit”
PATCH /v1/notes/Home.md
Content-Type: application/json
{
"ops": [
{ "op": "insert_after_heading", "heading": "## Today",
"markdown": "- [ ] follow up with Alice\n" },
{ "op": "set_frontmatter", "key": "updated", "value": "2026-05-03" }
]
}

Patch grammar matches the CLI (see CLI.md §3.1 and SPEC.md §6.2).

Hard delete. Returns 204 with no body. The path is added to a 5-minute tombstone so subsequent reads return 410 (vs 404), giving the agent a chance to detect “this was deleted, not missing-from-the-start”.

{ "to": "Archive/Home.md", "update_links": true }

Returns:

{ "ok": true, "data": { "from": "Home.md", "to": "Archive/Home.md", "rewrites": 7 } }

Query:

  • path_glob (e.g. Projects/**)
  • tag (repeatable)
  • frontmatter (key=value, repeatable)
  • limit (default 50, max 1000)
  • cursor

Accept: application/x-ndjson streams one JSON object per line, no envelope, no cursor — useful for very large vaults.

GET /v1/search?q=runbook&mode=hybrid&tag=ops&limit=20

Response:

{
"ok": true,
"data": {
"results": [
{
"path": "Ops/Runbook.md",
"title": "Runbook",
"score": 0.812,
"snippet": "…the **runbook** for incident response…",
"tags": ["ops", "oncall"],
"matched_in": ["title", "body"]
}
],
"next_cursor": null,
"mode_used": "hybrid",
"warnings": []
}
}

If mode=hybrid|semantic is requested without embeddings configured, mode_used will be lexical and warnings will include embeddings_unavailable.

These are direct mappings of the resources in §5; payload shapes are in the OpenAPI spec. Notably:

  • GET /v1/canvas/{path} returns the JSON Canvas object verbatim, plus indx-added etag and mtime_ms.
  • PATCH /v1/canvas/{path} accepts canvas patch ops (add_node, update_node, add_edge, …).
  • GET /v1/bases/{path}/query returns NDJSON rows when Accept: application/x-ndjson, otherwise a paginated array.
GET /v1/events
Accept: text/event-stream
Authorization: Bearer …

Each event is one JSON object per data: field:

event: note.updated
data: {"path":"Home.md","etag":"…","at":"2026-05-03T01:23:45Z","actor":{"kind":"api"}}
event: note.deleted
data: {"path":"Old.md","at":"…","actor":{"kind":"fs"}}

Filters via query string: ?paths=Projects/**&kinds=note.updated,note.created. Resume after disconnect via standard SSE Last-Event-ID (every event has a monotonic id).

GET /v1/vault/status → counts, last reindex, embedding status
POST /v1/vault/reindex → kick a full reindex; 202 with a job id; subscribe via /v1/events
GET /v1/vault/config → effective merged config
PUT /v1/vault/config → patch config; returns 200 + new config
EndpointAccept: application/json (default)Accept: application/x-ndjsonAccept: text/event-stream
/v1/notes (list)paginated arrayNDJSON, no cursorn/a
/v1/searchpaginated arrayNDJSON, no cursorn/a
/v1/bases/{p}/querypaginated arrayNDJSONn/a
/v1/eventsn/an/aSSE

A write request with Idempotency-Key: <uuid>:

  • The first call processes normally and caches the response (status + body) keyed by (token, method, path, key) for 24h.
  • Subsequent calls with the same key return the cached response with header X-Indx-Idempotent-Replay: true.
  • A subsequent call with the same key but a different request body returns 409 idempotency_key_reused.

This pairs naturally with If-Match for safe agent retries.

Per token, sliding window: 100 rps, 1000 burst by default. 429 with:

Retry-After: 1
X-RateLimit-Limit: 100
X-RateLimit-Remaining: 0
X-RateLimit-Reset: 1746230401

GET /openapi.json returns the full OpenAPI 3.1 document. The CLI indx schema openapi prints the same. Use it to:

  • Generate typed clients (e.g. openapi-typescript).
  • Validate request bodies before sending.
  • Discover new capabilities after a server upgrade.

/openapi.json reflects the running server’s exact capabilities including any feature flags (e.g. embeddings ops are absent if not configured).

/v1/ai/* is the HTTP transport for the operations defined in AI.md. The contract:

  • All AI ops are POST and accept a JSON body validated by a Zod schema in @indx/shared (SPEC §14). Request shapes match AI.md §5 field-for-field.
  • Responses use the same { ok, data | error } envelope as the rest of /v1.
  • Idempotency-Key is honored on every AI op (yes — even read-shaped ones, so retries don’t re-charge generation). See AI.md §8.2.
  • Streaming: Accept: text/event-stream switches the response to an SSE stream of ai.partial, ai.citation, ai.usage, ai.complete events. Non-streaming clients send Accept: application/json (default).
  • /v1/ai/status is a GET returning { enabled, provider, model, embeddings, cache, spend_today_usd }. Cheap; never depends on a provider call.
  • vault:read is sufficient for /v1/ai/summarize, /v1/ai/ask, /v1/ai/relate, /v1/ai/toc (mode: "note" or mode: "moc" without write), and the suggest-mode of /v1/ai/tag, /v1/ai/metadata, /v1/ai/extract. Apply-mode (apply: true on tag/metadata/extract or write on toc) requires vault:write and honors per-path if_match for optimistic concurrency. /v1/ai/status requires vault:read.
  • Errors include the AI-specific codes added to §4.1 (ai_unavailable, ai_quota_exceeded, ai_provider_error, ai_grounding_failed, ai_input_too_large, ai_schema_invalid, ai_apply_conflict).
POST /v1/ai/ask
Authorization: Bearer indx_…
Content-Type: application/json
Accept: text/event-stream
Idempotency-Key: 0c3e8e9c-…
{ "question": "what does the etag scheme guarantee?", "retrieve": { "mode": "hybrid", "top_k": 8 } }
event: ai.partial
data: {"delta":"The ETag is the first 16 hex of"}
event: ai.citation
data: {"citation":{"path":"SPEC.md","etag":"…","anchor":"6.1 Atomic write"}}
event: ai.complete
data: {"answer":"…","confidence":"high","citations":[…],"usage":{…},"warnings":[]}
  • URL prefix /v1 is stable; additive changes only.
  • Breaking changes ship under /v2 with /v1 kept available for one minor release.
  • A Deprecation: <date> header appears on any path scheduled for removal, with a Link: <docs>; rel="deprecation".