Skip to main content
API reference sections

HTTP API reference

Documents and search

Document location and outline, search, and read-model queries over attributes and tasks.

6 operations. The contract has no example values, so example bodies are generated from each schema: every field is shown with a placeholder of its type.

Resolve a stable document id to its owning profile and workspace

GET/v1/documents/{id}/location

the document's current location

Authorization
Bearer token with read_documents

Parameters

NameInTypeDescription
idrequiredpathstring

Responses

StatusDescriptionBody
200See above.DocumentLocation
401

no bearer token, or one that is not recognised

Failure
403

the credential does not hold the required scope

Failure
Example 200 response
{
  "profile": "00000000-0000-4000-8000-000000000000",
  "workspace": "00000000-0000-4000-8000-000000000000",
  "document": "00000000-0000-4000-8000-000000000000"
}
Example request
curl "http://127.0.0.1:$PORT/v1/documents/$ID/location" \
  -H "Authorization: Bearer $KORU_TOKEN"

The document's headings, from the read model, without opening it

GET/v1/documents/{id}/outline

headings in reading order, and whether the document is indexed yet

Authorization
Bearer token with read_documents

Parameters

NameInTypeDescription
idrequiredpathstring

Responses

StatusDescriptionBody
200See above.DocumentOutline
401

no bearer token, or one that is not recognised

Failure
403

the credential does not hold the required scope

Failure
Example 200 response
{
  "document": "00000000-0000-4000-8000-000000000000",
  "headings": [
    {
      "node": "string",
      "level": 0,
      "text": "string"
    }
  ],
  "projected": false
}
Example request
curl "http://127.0.0.1:$PORT/v1/documents/$ID/outline" \
  -H "Authorization: Bearer $KORU_TOKEN"

Which nodes carry these attributes, across workspaces, from the read model

POST/v1/attributes/query

a page of rows - each carrying workspace, document and node together, every attribute the node holds, an excerpt, and the source document's title and inherited trashed flag - plus the attribute definitions and tag tables in scope, so a filter UI builds from types. A POST because the query travels in the body: a filter can name an attribute key or a tag, and a URL is written to the access log. group_by: "tag" adds groups - a count per tag over the whole matching corpus rather than the page, with merged tags folded into the tag they were merged into and binned rows counted and flagged. updating_workspaces names any workspace the answer may be behind on rather than waiting for it

Authorization
Bearer token with read_documents

Request body (required)

application/json AttributeQuery

FieldTypeDescription
workspacesarray of WorkspaceId

Which workspaces to search. Empty means every workspace this store holds.

Default []

hasstring or null

Only nodes carrying this attribute key (koru.due, or a custom definition's key).

Default null

missingstring or null

Only nodes not carrying this key. Combined with the base rule this reads "has some attribute, but not this one".

Default null

tagsarray of string

Only nodes carrying any of these tags, each a TagId in storage form. Ids, never names - the tag table owns names and rename must not change what a saved query matches.

Default []

prioritystring or null

Only nodes whose koru.priority equals this token (low | medium | high | urgent).

Default null

min_prioritystring or null

Only nodes whose koru.priority is this token or more important - the semantic order low < medium < high < urgent, never the alphabetic one.

Default null

dueDueFilter or null

Which koru.due values match, stated per temporal type.

Default null

sortstring or null

due, priority or modified. Absent means modified - newest first, which is what a surface with no opinion wants.

Default null

group_bystring or null

Ask for corpus-wide counts beside the page. tag is the only token; absent means no groups, and the response's groups is empty.

It is a separate question from paging, deliberately: a client can count a page itself, and what it cannot do is count the rows it did not fetch. So the counts come from their own statement over the same filter, and a group's count is the whole matching corpus rather than the page above it.

Default null

limitinteger (uint, >= 0) or null

Rows per page. Absent means the server default; the server also caps it, so a huge number is a full page rather than a full table.

Default null

offsetinteger (uint, >= 0) or null

Rows to skip. Page by increasing this until a short page comes back.

Default null

Example request body
{
  "workspaces": [],
  "has": "string",
  "missing": "string",
  "tags": [],
  "priority": "string",
  "min_priority": "string",
  "due": {
    "dates": {
      "after": "string",
      "before": "string"
    },
    "instants": {
      "after_ms": 0,
      "before_ms": 0
    }
  },
  "sort": "string",
  "group_by": "string",
  "limit": 0,
  "offset": 0
}

Responses

StatusDescriptionBody
200See above.AttributeQueryResponse
401

no bearer token, or one that is not recognised

Failure
403

the credential does not hold the required scope

Failure
Example 200 response
{
  "rows": [
    {
      "workspace": "00000000-0000-4000-8000-000000000000",
      "document": "00000000-0000-4000-8000-000000000000",
      "node": "string",
      "node_kind": "string",
      "excerpt": "string",
      "document_title": "string",
      "trashed": false,
      "attributes": [
        {
          "id": "string",
          "role": "string",
          "kind": "string",
          "value": "string",
          "zone": "string",
          "label": "string"
        }
      ]
    }
  ],
  "definitions": [
    {
      "id": "string",
      "key": "string",
      "label": "string",
      "value_type": "string",
      "cardinality": "string",
      "scope": "string",
      "system": false,
      "options": [],
      "filterable": false,
      "sortable": false,
      "groupable": false
    }
  ],
  "tags": [
    {
      "id": "string",
      "name": "string",
      "color": "string",
      "workspace": {}
    }
  ],
  "groups": [],
  "projected": false,
  "updating_workspaces": [],
  "limit": 0,
  "offset": 0
}
Example request
curl -X POST "http://127.0.0.1:$PORT/v1/attributes/query" \
  -H "Authorization: Bearer $KORU_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
  "workspaces": [],
  "has": "string",
  "missing": "string",
  "tags": [],
  "priority": "string",
  "min_priority": "string",
  "due": {
    "dates": {
      "after": "string",
      "before": "string"
    },
    "instants": {
      "after_ms": 0,
      "before_ms": 0
    }
  },
  "sort": "string",
  "group_by": "string",
  "limit": 0,
  "offset": 0
}'

What is outstanding, across workspaces, from the read model

POST/v1/tasks/query

a page of tasks - each carrying workspace, document and node together, the task: reference that is both its identity and the page cursor, its lifecycle, its title, the breadcrumb that says where it lives, both trash answers under their own names, and every attribute the node holds - plus the attribute definitions and tag tables in scope, so a row draws without a second request. A POST for the same reason the attribute query is one: the filter travels in the body rather than in a URL that is written to the access log. Paging is a keyset on the task reference, so next_after continues and restarted says the cursor named a row the filter no longer matches. updating_workspaces names any workspace the answer may be behind on rather than waiting for it

Authorization
Bearer token with read_documents

Request body (required)

application/json TaskQuery

FieldTypeDescription
workspacesarray of WorkspaceId

Which workspaces to search. Empty means every workspace this store holds.

Default []

hasstring or null

Only tasks carrying this attribute key (koru.due, or a custom definition's key).

Default null

missingstring or null

Only tasks not carrying this key.

Default null

tagsarray of string

Only tasks carrying any of these tags, each a TagId in storage form. Ids, never names - the tag table owns names and rename must not change what a saved query matches.

Default []

prioritystring or null

Only tasks whose koru.priority equals this token (low | medium | high | urgent).

Default null

min_prioritystring or null

Only tasks whose koru.priority is this token or more important - the semantic order low < medium < high < urgent, never the alphabetic one.

Default null

dueDueFilter or null

Which koru.due values match, stated per temporal type.

Default null

bucketstring or null

Only tasks in one agenda bucket - today, next, upcoming, later or completed (SPEC §7).

Usable only with [`bucket_windows`](Self::bucket_windows), which is where the reader's zone was resolved; naming one without the other is a 400, as is a token that is not one of the five. A bucket is never persisted and never guessed - a store that invented today's date would be wrong by morning and wrong in another country.

A bucket names a lifecycle, so it supersedes [`lifecycle`](Self::lifecycle). Completed is one of the five and the other four are open by rule 1 of the bucket function, so ANDing the two fields would answer bucket: "completed" with an empty page under the default lifecycle - a silent trap on the most obvious request. When this field is present, the query's lifecycle is not applied.

Default null

bucket_windowsBucketWindows or null

The reader's zone, already resolved into days and instants - required by [bucket](Self::bucket) and by group_by: "bucket".

Default null

completed_on_or_afterstring or null

Hide tasks completed before this day - a canonical YYYY-MM-DD, inclusive, which the caller computed from its own today and [COMPLETED_WINDOW_DAYS].

It applies to completed rows and to nothing else, so it narrows the Completed queue and leaves the four open ones exactly as they were.

A task the store cannot date is kept. A completion recorded before this build carries no day, and a store that cannot tell how old something is must not call it old: hiding those would take work out of sight that nobody could ask for back. They age out as they are reopened, finished again, or taken off Tasks.

Default null

recently_completed_sincestring or null

Keep tasks completed on or after this day where they were, rather than only in the Completed queue: a canonical YYYY-MM-DD the caller computed from its own today.

What it loosens is where a completed task is listed, not what a task is. A queue places a row by its due when this covers it, so something ticked this morning stays on the page it was ticked on, struck through, instead of vanishing; a list shows it beside the open work in it. Nothing else about the row changes, and the row is still in the Completed queue as well - the two pages are two questions.

The counts do not carry it, deliberately: a queue's number is how much open work is in it, and ticking something should make that number go down rather than leave it where it was.

Default null

liststring or null

Only tasks in this list - a ListId in storage form.

A list is a filter, not a place (SPEC §8): membership is one koru.list value on the task, so this is one more predicate over one more attribute and ANDs with every filter above it. Ids, never names - the workspace's list table owns names, and renaming a list must not change what a saved query matches. An id no table names matches nothing.

Default null

group_bystring or null

Ask for corpus-wide counts beside the page. list or bucket; absent means no groups, and the response's groups is empty. An unknown token is a 400, never a silently ungrouped answer.

The attribute query's rule, applied here: a client can count a page itself, and what it cannot do is count the rows it did not fetch - so a sidebar's "14" comes from its own statement over the same filter rather than from the page above it.

bucket requires [bucket_windows](Self::bucket_windows) and answers five rows per workspace, one per bucket - the rail's Today 5 · 3 overdue, Next 12, Upcoming 40, Later 9, Completed 118. It partitions by lifecycle (Completed is one of the five), so it does not apply [lifecycle](Self::lifecycle) either, for [bucket](Self::bucket)'s reason.

Default null

overdueDueFilter or null

Which dues count as overdue, stated per temporal type - used only to split groups, never to filter rows.

Overdue is a fact about the reader's display zone (SPEC §7) and the store carries no timezone database, so the caller computes the windows and sends them: typically dates.before = today and instants.before_ms = now. Absent means every group's overdue is zero, which is the honest answer to a question nobody asked.

Default null

lifecycleLifecycleFilter

Open, completed, or both. Absent means open - a task list answers what should I do next, and absence of koru.task_state is itself open.

Default "open"

include_trashedboolean

Also return tasks sitting in their document's own bin, each flagged with [TaskRow::in_trash]. Off by default; on is how a binned task stays restorable from a Tasks surface.

It does not govern binned documents: those rows are always returned, flagged with [TaskRow::document_trashed].

Default false

sortTaskSort

due, priority or modified. Absent means due - soonest first, undated last.

Default "due"

limitinteger (uint, >= 0)

Rows per page, clamped by the store; see [MAX_LIMIT].

Default 100

afterstring or null

The task: reference the previous page ended on, exclusive.

Keyset, never an offset. A Tasks view re-queries at every local midnight and on every resume, and an offset page silently repeats or skips a row when anything inserts above it. A reference that no longer matches the filter is not an error: the answer comes from the top with [TaskQueryResponse::restarted] set.

Default null

coveringarray of RevisionRequirement

Writes this answer must include for the caller to trust it.

A surface that has just written a document and re-asks the read model can be answered from a projection that has not seen the write yet - the store projects asynchronously and coalesces. Naming the write lets the answer say so through [TaskQueryResponse::covered], and the caller can ask again rather than draw a number it knows is behind.

Optional and additive. Absent means the caller is not waiting on anything; an older client never sends it. At most [MAX_COVERING] entries; a malformed one refuses the request rather than answering uncovered, because a malformed requirement is a client bug and a quiet answer would hide it. Pagination is untouched.

Default []

Example request body
{
  "workspaces": [],
  "has": "string",
  "missing": "string",
  "tags": [],
  "priority": "string",
  "min_priority": "string",
  "due": {
    "dates": {
      "after": "string",
      "before": "string"
    },
    "instants": {
      "after_ms": 0,
      "before_ms": 0
    }
  },
  "bucket": "string",
  "bucket_windows": {
    "today": "string",
    "horizon": "string",
    "upcoming_horizon": "string",
    "today_ends_ms": 0,
    "horizon_ends_ms": 0,
    "upcoming_ends_ms": 0,
    "now_ms": 0,
    "min_next_priority": "string"
  },
  "completed_on_or_after": "string",
  "recently_completed_since": "string",
  "list": "string",
  "group_by": "string",
  "overdue": {
    "dates": {
      "after": "string",
      "before": "string"
    },
    "instants": {
      "after_ms": 0,
      "before_ms": 0
    }
  },
  "lifecycle": "open",
  "include_trashed": false,
  "sort": "due",
  "limit": 100,
  "after": "string",
  "covering": []
}

Responses

StatusDescriptionBody
200See above.TaskQueryResponse
401

no bearer token, or one that is not recognised

Failure
403

the credential does not hold the required scope

Failure
Example 200 response
{
  "rows": [
    {
      "workspace": "00000000-0000-4000-8000-000000000000",
      "document": "00000000-0000-4000-8000-000000000000",
      "node": "string",
      "task": "string",
      "node_kind": "string",
      "lifecycle": "string",
      "document_trashed": false,
      "in_trash": false,
      "breadcrumb": "string",
      "title": "string",
      "attributes": [
        {
          "id": "string",
          "role": "string",
          "kind": "string",
          "value": "string",
          "zone": "string",
          "label": "string"
        }
      ]
    }
  ],
  "definitions": [
    {
      "id": "string",
      "key": "string",
      "label": "string",
      "value_type": "string",
      "cardinality": "string",
      "scope": "string",
      "system": false,
      "options": [],
      "filterable": false,
      "sortable": false,
      "groupable": false
    }
  ],
  "tags": [
    {
      "id": "string",
      "name": "string",
      "color": "string",
      "workspace": {}
    }
  ],
  "groups": [],
  "projected": false,
  "updating_workspaces": [],
  "limit": 0,
  "next_after": "string",
  "restarted": false,
  "covered": false
}
Example request
curl -X POST "http://127.0.0.1:$PORT/v1/tasks/query" \
  -H "Authorization: Bearer $KORU_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
  "workspaces": [],
  "has": "string",
  "missing": "string",
  "tags": [],
  "priority": "string",
  "min_priority": "string",
  "due": {
    "dates": {
      "after": "string",
      "before": "string"
    },
    "instants": {
      "after_ms": 0,
      "before_ms": 0
    }
  },
  "bucket": "string",
  "bucket_windows": {
    "today": "string",
    "horizon": "string",
    "upcoming_horizon": "string",
    "today_ends_ms": 0,
    "horizon_ends_ms": 0,
    "upcoming_ends_ms": 0,
    "now_ms": 0,
    "min_next_priority": "string"
  },
  "completed_on_or_after": "string",
  "recently_completed_since": "string",
  "list": "string",
  "group_by": "string",
  "overdue": {
    "dates": {
      "after": "string",
      "before": "string"
    },
    "instants": {
      "after_ms": 0,
      "before_ms": 0
    }
  },
  "lifecycle": "open",
  "include_trashed": false,
  "sort": "due",
  "limit": 100,
  "after": "string",
  "covering": []
}'

POST/v1/search

Queries eligible sources concurrently, preserves partial results and source-labelled failures, and returns a deterministic merged page.

Authorization
Bearer token with store.read_documents
Operation id
federated_search

Request body (required)

application/json FederatedSearchRequest

FieldTypeDescription
textrequiredstring
entity_pickerboolean

Restrict results to document/file titles and live task nodes for an @ picker. This is a query shape over the existing projection, not a stored index.

Default false

workspacesarray of WorkspaceId

Empty means every workspace the index knows. The caller resolves a profile to this; the index has never heard of one.

Default []

excluded_workspacesarray of WorkspaceId

Default []

kindsarray of string

Kind::as_str() values, as documents store them.

Default []

tagsarray of string

Tag names, not TagIds - this is a text-search surface and a person types the word they read. A hit's node must carry every one of them, and a name here matches its own descendants too (fun matches fun/home), so this is node-scoped filtering rather than a term: nothing here is searched for, ranked, or spelled back in [SearchPage::corrected_query] ([ADR-207](../../../docs/decisions/ADR-201-225.md#adr-207-tagfilter--a-tag-in-a-query-is-a-node-scoped-filter-not-a-term-and-it-lives-in-candidates-alone)).

A surface may fill this directly; a person fills it by typing #tag into [Self::text], which the store lifts out before it searches.

Default []

attributesarray of AttrFilter

Typed attribute filters. A hit's node must satisfy every one.

One general filter rather than a field per attribute, because a workspace defines its own attributes and declares their types: a wire naming priority and due explicitly would need a new field for every attribute anybody invents, while key + kinds serves a custom one on the day it exists.

Default []

sortSearchSort

Default "relevance"

lifecycleLifecycleFilter

Default "live"

page_sizeinteger (uint, >= 0)

Clamped by the store; see [MAX_PAGE_SIZE].

Default 50

offsetinteger (uint, >= 0)

Default 0

request_seqinteger (uint64, >= 0)

Copied to the response, so a surface typing quickly can drop an answer to a keystroke it has already moved past.

Default 0

include_facetsboolean

Default false

recent_queriesinteger (uint, >= 0) or null

Default null

include_aiboolean

Default true

cursorstring or null

Default null

profileProfileId or null

Only this profile's chats among the AI results (AI schema 13): a window searches its own profile's. Absent searches every profile's, each hit naming its own. The Store results are scoped by workspaces, as before.

Example request body
{
  "text": "string",
  "entity_picker": false,
  "workspaces": [],
  "excluded_workspaces": [],
  "kinds": [],
  "tags": [],
  "attributes": [],
  "sort": "relevance",
  "lifecycle": "live",
  "page_size": 50,
  "offset": 0,
  "request_seq": 0,
  "include_facets": false,
  "recent_queries": 0,
  "include_ai": true,
  "cursor": "string",
  "profile": "00000000-0000-4000-8000-000000000000"
}

Responses

StatusDescriptionBody
200

A merged page plus per-source status

FederatedSearchResponse
400

The request or opaque cursor is invalid

401

The gateway bearer is absent or invalid

Example 200 response
{
  "page": {
    "hits": [
      {
        "hit": {
          "workspace": "string",
          "workspace_name": "string",
          "document": "string",
          "node": "string",
          "document_kind": "",
          "mime": "string",
          "kind": "string",
          "field": "string",
          "title": "string",
          "breadcrumb": "string",
          "breadcrumb_spans": [
            {
              "start": 0,
              "end": 0
            }
          ],
          "snippet": "string",
          "spans": [
            {
              "start": 0,
              "end": 0
            }
          ],
          "lifecycle": "live",
          "trashed_at": 0,
          "trashed_by": "string",
          "score": 0,
          "observed_modified_at_ms": 0,
          "workspace_last_used_at_ms": 0
        },
        "source": "store"
      }
    ],
    "next_cursor": "string",
    "updating_workspaces": [
      "string"
    ],
    "request_seq": 0,
    "corrected_query": "string"
  },
  "facets": {
    "workspaces": [
      {
        "workspace": "string",
        "workspace_name": "string"
      }
    ]
  },
  "recent_queries": [
    {
      "text": "string",
      "used_at_ms": 0
    }
  ],
  "sources": [
    {
      "source": "store",
      "state": "complete",
      "error_code": "string"
    }
  ]
}
Example request
curl -X POST "http://127.0.0.1:$PORT/v1/search" \
  -H "Authorization: Bearer $KORU_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
  "text": "string",
  "entity_picker": false,
  "workspaces": [],
  "excluded_workspaces": [],
  "kinds": [],
  "tags": [],
  "attributes": [],
  "sort": "relevance",
  "lifecycle": "live",
  "page_size": 50,
  "offset": 0,
  "request_seq": 0,
  "include_facets": false,
  "recent_queries": 0,
  "include_ai": true,
  "cursor": "string",
  "profile": "00000000-0000-4000-8000-000000000000"
}'

Record that a search led somewhere

POST/v1/search/navigated

nothing. Machine-local and derived: it feeds recent queries and a workspace's recency, and reaches no document and no shared profile. A query earns a place in the history only by leading somewhere, which is why the text arrives here rather than on the search

Authorization
Bearer token with edit_documents

Request body (required)

application/json Navigation

FieldTypeDescription
workspacerequiredWorkspaceId
documentstring or null

Default null

nodestring or null

Default null

querystring

What was typed to get here. Empty is allowed and records nothing.

Default ""

Example request body
{
  "workspace": "00000000-0000-4000-8000-000000000000",
  "document": "string",
  "node": "string",
  "query": ""
}

Responses

StatusDescriptionBody
200See above.
401

no bearer token, or one that is not recognised

Failure
403

the credential does not hold the required scope

Failure
Example request
curl -X POST "http://127.0.0.1:$PORT/v1/search/navigated" \
  -H "Authorization: Bearer $KORU_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
  "workspace": "00000000-0000-4000-8000-000000000000",
  "document": "string",
  "node": "string",
  "query": ""
}'

Generated from contract version 1. Last updated .

Privacy choices

Choose which optional tools Koru may use on this site. Your choice is saved in a cookie in this browser for 12 months. Privacy policy

Privacy choices

Keep you signed in, protect forms and remember your language and this choice.

Always on

PostHog and Google Analytics 4 measure page views and actions such as downloads, using cookies or local storage.

PostHog records how pages are used, with form inputs masked and private pages excluded. Needs Analytics.

Ahrefs Web Analytics measures visits and where they come from, and Google Ads measures whether our ads led to a visit or download.