The 13 actions
The order below is the order of KNOWLEDGE_ACTIONS. Examples show the engine call; the model sends the same arguments as JSON. Response shapes are abridged: ... marks elided content, and // comments are explanations, not part of the payload.
discover
Needs: Always available
When to use it. First, whenever the agent does not already know what is in scope. One call returns the documents, what is inside each one and which actions this deployment can run, so later calls can use real sheet, column, section and field names.
| Argument | Type | Notes |
|---|
| action | string | "discover" |
| source_ids | string[] | Narrow to these sources (intersected with the ceiling). |
| document_ids | string[] | Narrow to these documents. Also asks for their structure in full, past the cap a whole-page discover applies. |
| limit | integer | Documents per page. Default 50, clamped to 1 to 200. |
| cursor | string | The next_cursor of a previous page, sent back verbatim. |
answer = await engine.search_knowledge_base(
action="discover",
principals=caller,
scope=["hr", "finance"],
)
const answer = await engine.searchKnowledgeBase({
action: "discover",
principals: caller,
scope: ["hr", "finance"],
});
{
"success": true,
"documents": [
{"id": "...", "name": "costs.xlsx", "source_id": "finance",
"kind": "spreadsheet", // "spreadsheet" or "text", decided by MIME
"document_type": "...", "mode": "hybrid", "failure_reason": null,
"structure": {...}} // sheets / sections + last_page / keys / chunks
],
"count": 50, "has_more": true,
"next_cursor": "...", "next_page": {"action": "discover", "cursor": "..."},
"document_types": [...], // census of the whole scope
"fields_by_type": {...}, // extracted field names, types, counts
"available_actions": {"search": "...", "compute": "... (OFF)", ...},
"next_action": {"for a clause or a fact": {"action": "search", "query": "..."}, ...}
}
A spreadsheet structure lists sheets with name, column headers, row count and frame_key (the name compute will give that sheet). A document with headings lists sections and last_page, JSON lists keys, anything else falls back to chunks.
Lists inside a structure are capped, with the rest reported as more_columns, more_sections, more_keys or more_sheets. Naming the documents in document_ids returns them whole.
fields_by_type and document_types are computed over the whole scope, not only the current page.
available_actions covers the other twelve actions. An action this deployment cannot run is listed with " (OFF)" appended, never hidden.
search
Needs: Always available
When to use it. Questions answered by reading text. To look up an ID, code, SKU or invoice number, send the bare identifier alone (for example "2525"), not the whole question.
| Argument | Type | Notes |
|---|
| action | string | "search" |
| query | string | Required. Words, or a bare content identifier. |
| source_ids | string[] | Narrow to these sources. |
| document_ids | string[] | Narrow to these documents (intersects with source_ids). |
| top_k | integer | How many passages. Default 10. |
| mode | "hybrid" | "graph" | Omit to let the engine decide from the documents in scope. "hybrid" forces wording only, "graph" forces the connection signal on. |
answer = await engine.search_knowledge_base(
action="search",
query="annual leave carry over",
principals=caller,
scope=["hr", "finance"],
)
const answer = await engine.searchKnowledgeBase({
action: "search",
query: "annual leave carry over",
principals: caller,
scope: ["hr", "finance"],
});
{
"success": true,
"hits": [
{"document_id": "...", "document_name": "HR leave policy", "chunk_text": "...",
"score": 0.83, "source_id": "hr", "chunk_idx": 4}
],
"usage": {...},
"hint": {"action": "compute", "reason": "aggregate question over a spreadsheet"} // only on a mixed scope
}
chunk_idx is where the passage sits in its document. Pass it to get_chunks as start to read around it.
A query that names a file (for example "what is in report_2024.xlsx") searches document names instead and adds "matched_by": "filename" to the same hit shape. mode is ignored on that path. If no visible document has that name, the ordinary search runs. Turn it off with filename_search=False (filenameSearch: false).
An aggregate question (sum, total, average, count, top, per, a "by column" grouping, a numeric comparison and so on) over a scope where every document is a spreadsheet is refused with a next_action pointing at compute. See refusals below. Turn it off with redirect_aggregates_to_compute=False (redirectAggregatesToCompute: false).
get_doc
Needs: Always available
When to use it. Reading one whole document by its system id, taken from discover, list or a search hit. Never pass an invoice number or other content identifier here.
| Argument | Type | Notes |
|---|
| action | string | "get_doc" |
| document_id | string (uuid) | Required. |
answer = await engine.search_knowledge_base(
action="get_doc",
document_id="3f2b8c1e-5a4d-4e8f-9b61-2c7d0a9e4f10",
principals=caller,
scope=["hr", "finance"],
)
const answer = await engine.searchKnowledgeBase({
action: "get_doc",
document_id: "3f2b8c1e-5a4d-4e8f-9b61-2c7d0a9e4f10",
principals: caller,
scope: ["hr", "finance"],
});
{
"success": true,
"document": {
"id": "...", "name": "...", "text": "...", "acl": [...], "status": "completed",
...,
"failure_reason": null, // one FAILURE_REASONS code, or null
"failure_message": null // the fixed sentence for that code
}
}
The raw error string of a failed ingest is never returned here; the code and its fixed sentence are. The operator surfaces (engine.get_document, list_documents, DocumentReport.error) keep the raw string.
A document that is absent, not visible to the caller, or outside the host ceiling all answer the same "document not found: <id>".
get_docs
Needs: Always available
When to use it. Several whole documents at once, by id.
| Argument | Type | Notes |
|---|
| action | string | "get_docs" |
| document_ids | string[] | Required. |
| max_chars | integer | Total document text to return before stopping. Default 200,000, floor 1,000. |
answer = await engine.search_knowledge_base(
action="get_docs",
document_ids=["3f2b8c1e-5a4d-4e8f-9b61-2c7d0a9e4f10", "8d1e7f02-6b3c-4a59-8e2d-1f0c9b7a6e35"],
principals=caller,
scope=["hr", "finance"],
)
const answer = await engine.searchKnowledgeBase({
action: "get_docs",
document_ids: ["3f2b8c1e-5a4d-4e8f-9b61-2c7d0a9e4f10", "8d1e7f02-6b3c-4a59-8e2d-1f0c9b7a6e35"],
principals: caller,
scope: ["hr", "finance"],
});
{
"success": true,
"documents": [{...}, {...}], // same per-document shape as get_doc
"count": 2,
"remaining_document_ids": ["..."], // only when the budget ran out
"next_page": {"action": "get_docs", "document_ids": ["..."]}
}
An id that is absent or not visible is simply missing from documents, never an error.
The first document is always returned, even if it alone exceeds max_chars.
get_chunks
Needs: Always available
When to use it. Walking one long document in order, a range at a time, or reading around a search hit by starting at its chunk_idx.
| Argument | Type | Notes |
|---|
| action | string | "get_chunks" |
| document_id | string (uuid) | Required. |
| start | integer | First chunk position, 0-based. Default 0. |
| end | integer | Last position, inclusive. At most 25 chunks come back per call whatever is asked. |
answer = await engine.search_knowledge_base(
action="get_chunks",
document_id="3f2b8c1e-5a4d-4e8f-9b61-2c7d0a9e4f10",
start=4,
principals=caller,
scope=["hr", "finance"],
)
const answer = await engine.searchKnowledgeBase({
action: "get_chunks",
document_id: "3f2b8c1e-5a4d-4e8f-9b61-2c7d0a9e4f10",
start: 4,
principals: caller,
scope: ["hr", "finance"],
});
{
"success": true,
"document_id": "...", "total_chunks": 120, "start": 4, "end": 28,
"chunks": [{"position": 4, "text": "...", "language": "en"}, ...],
"has_more": true, "next_start": 29,
"next_page": {"action": "get_chunks", "document_id": "...", "start": 29}
}
list
Needs: Always available
When to use it. Browsing documents without searching. Cheaper than discover: it does not fetch structure, fields or the census.
| Argument | Type | Notes |
|---|
| action | string | "list" |
| source_ids | string[] | Narrow to these sources. |
| document_ids | string[] | Narrow to these documents. |
| limit | integer | Default 50, clamped to 1 to 200. |
| cursor | string | The next_cursor of a previous page. |
answer = await engine.search_knowledge_base(
action="list",
source_ids=["hr"],
limit=20,
principals=caller,
scope=["hr", "finance"],
)
const answer = await engine.searchKnowledgeBase({
action: "list",
source_ids: ["hr"],
limit: 20,
principals: caller,
scope: ["hr", "finance"],
});
{
"success": true,
"documents": [{"id": "...", "name": "...", "source_id": "hr", "kind": "text",
"document_type": null, "mode": "hybrid", "failure_reason": null,
"status": "completed", "parked": null}],
"count": 20, "has_more": true,
"next_cursor": "...", "next_page": {"action": "list", "cursor": "..."}
}
A malformed cursor is refused with "invalid cursor", never read as page one.
A document the host model deferred reads status "waiting_model", not failed, and parked says where it stands: stage, parked_at, retry_at, rounds, request_keys, last_skip_reason, last_skip_at.
Needs: An LLM configured
When to use it. The structured fields a question names (dates, amounts, parties) and each document’s value for them. It does not filter or compare by value: read the values and compare them yourself.
| Argument | Type | Notes |
|---|
| action | string | "query_meta" |
| query | string | Required. A question naming a field from fields_by_type. |
| source_ids | string[] | Narrow to these sources. |
| limit | integer | Candidates to return. Default 20, clamped to 1 to 100. |
answer = await engine.search_knowledge_base(
action="query_meta",
query="invoice amount and invoice date",
principals=caller,
scope=["hr", "finance"],
)
const answer = await engine.searchKnowledgeBase({
action: "query_meta",
query: "invoice amount and invoice date",
principals: caller,
scope: ["hr", "finance"],
});
{
"success": true,
"question": "...",
"resolved_fields": [...],
"documents": [{"id": "...", "source_id": "...", "name": "...",
"document_type": "...", "structured_data": {...}, ...}],
"count": 3
}
Refused for document_ids. The same aggregate refusal as search applies over an all-spreadsheet scope.
compute
Needs: An LLM and enable_code_execution, or a host compute runner
When to use it. Any figure derived from spreadsheets (total, average, count, ranking, margin, comparison) and finding the exact row that matches one id or value. It runs over every row of the sheets in scope, not a sample.
| Argument | Type | Notes |
|---|
| action | string | "compute" |
| query | string | Required. What to compute, in plain words, with columns named as discover spelled them. |
| source_ids | string[] | Narrow to these sources. |
| document_ids | string[] | Narrow to these documents. |
answer = await engine.search_knowledge_base(
action="compute",
query="total amount by region in costs.xlsx",
principals=caller,
scope=["hr", "finance"],
)
const answer = await engine.searchKnowledgeBase({
action: "compute",
query: "total amount by region in costs.xlsx",
principals: caller,
scope: ["hr", "finance"],
});
{
"success": true, // whether the generated code ran
"result": ...,
"code": "...", "stdout": "", "error": null,
"execution_time": 0.41, // executionTime in TypeScript
"documents_used": ["..."], // documentsUsed in TypeScript
"provider_tokens": {"llm_input": 812, "llm_output": 164}, // providerTokens in TypeScript
"attempts": 1 // 1 or 2 code-generation calls
}
Nothing tabular in scope comes back as a result, for example {"success": false, "error": "no tabular (CSV/TSV/XLSX) documents found in scope for compute()"}, not as a transport error.
The whole answer goes through the redaction policy except the top-level list of document ids.
map_reduce
Needs: An LLM configured, or a host map_reduce runner
When to use it. Asking the same question of every document in scope and getting one answer per document, for example "which contracts mention X", where search would return a handful of passages and miss the rest. Not for figures: that is compute.
| Argument | Type | Notes |
|---|
| action | string | "map_reduce" |
| query | string | Required. The question to ask of each document. |
| source_ids | string[] | Narrow to these sources. |
| document_ids | string[] | Narrow to these documents. |
| limit | integer | Documents to read, newest first. Default 25, at most 200. |
answer = await engine.search_knowledge_base(
action="map_reduce",
query="Does this contract have an auto-renewal clause?",
principals=caller,
scope=["hr", "finance"],
)
const answer = await engine.searchKnowledgeBase({
action: "map_reduce",
query: "Does this contract have an auto-renewal clause?",
principals: caller,
scope: ["hr", "finance"],
});
{
"success": true,
"results": [
{"document_id": "...", "document_name": "...", "data": {...}},
{"document_id": "...", "document_name": "...", "error": "..."}
],
"processed": 24, "failed": 1, "considered": 25,
"hint": {"action": "compute", "reason": "..."} // only when some targets are spreadsheets
}
Refused for any instruction when every document the call would read is a spreadsheet, because it reads a slice of each document and would merge partial totals. The check runs before any model call and before a host runner.
get_neighbors
Needs: graph.enabled
When to use it. What is one step from a named thing, and which way each link points.
| Argument | Type | Notes |
|---|
| action | string | "get_neighbors" |
| entity | string | Required. An entity id from an earlier graph answer (preferred), or its name as written in the documents. |
| source_ids | string[] | Narrow to these sources. |
| limit | integer | Default 20, clamped to 1 to 200. |
answer = await engine.search_knowledge_base(
action="get_neighbors",
entity="Acme Corp",
principals=caller,
scope=["hr", "finance"],
)
const answer = await engine.searchKnowledgeBase({
action: "get_neighbors",
entity: "Acme Corp",
principals: caller,
scope: ["hr", "finance"],
});
{
"success": true,
"entity": {"id": "...", "name": "acme corp", "type": "ORG"},
"neighbors": [{"id": "...", "name": "...", "type": "PERSON", "category": "MEMBERSHIP",
"label": "works_for", "evidence": "...", "direction": "incoming"}],
"count": 7
}
traverse
Needs: graph.enabled
When to use it. Everything within a few steps of a named thing.
| Argument | Type | Notes |
|---|
| action | string | "traverse" |
| entity | string | Required. An entity id (preferred) or name. |
| depth | integer | Hops, 1 to 5. Default 2. |
| category | enum | Only follow this kind of connection. |
| source_ids | string[] | Narrow to these sources. |
| limit | integer | Paths. Default 50, clamped to 1 to 200. |
answer = await engine.search_knowledge_base(
action="traverse",
entity="Acme Corp",
depth=2,
category="HIERARCHICAL",
principals=caller,
scope=["hr", "finance"],
)
const answer = await engine.searchKnowledgeBase({
action: "traverse",
entity: "Acme Corp",
depth: 2,
category: "HIERARCHICAL",
principals: caller,
scope: ["hr", "finance"],
});
{
"success": true,
"entity": {"id": "...", "name": "...", "type": "ORG"},
"paths": [{"target_id": "...", "target": "...", "target_type": "PERSON",
"category": "HIERARCHICAL", "label": "reports_to", "evidence": "...", "hops": 2}],
"count": 12
}
Needs: graph.enabled
When to use it. Connections of a kind across the corpus, with no starting entity (an entity is refused; for how two named things connect, traverse from one). Also the way in when every name in an answer is masked: take a source_id or target_id from its result.
| Argument | Type | Notes |
|---|
| action | string | "find_related" |
| category | enum | The kind of connection. At least one of category, label or entity_type is required. |
| label | string | The exact relationship wording as extracted, for example reports_to. |
| entity_type | enum | Keep only relationships with an entity of this type on one end. |
| source_ids | string[] | Narrow to these sources. |
| limit | integer | Relationships. Default 50, clamped to 1 to 200. |
answer = await engine.search_knowledge_base(
action="find_related",
category="CREATION",
entity_type="PRODUCT",
principals=caller,
scope=["hr", "finance"],
)
const answer = await engine.searchKnowledgeBase({
action: "find_related",
category: "CREATION",
entity_type: "PRODUCT",
principals: caller,
scope: ["hr", "finance"],
});
{
"success": true,
"relationships": [{"source_id": "...", "source": "...", "source_type": "ORG",
"target_id": "...", "target": "...", "target_type": "PRODUCT",
"category": "CREATION", "label": "makes", "evidence": "..."}],
"count": 9
}
Needs: graph.enabled and graph.community_summaries
When to use it. The themes each source groups into, ranked against a question.
| Argument | Type | Notes |
|---|
| action | string | "community_summary" |
| query | string | Optional. Without one, the main themes. A question no summary clears the relevance floor for gets the closest themes, marked loose_match. |
| source_ids | string[] | Narrow to these sources. |
| limit | integer | Communities. Default 3, clamped to 1 to 10. |
answer = await engine.search_knowledge_base(
action="community_summary",
query="supplier risk",
principals=caller,
scope=["hr", "finance"],
)
const answer = await engine.searchKnowledgeBase({
action: "community_summary",
query: "supplier risk",
principals: caller,
scope: ["hr", "finance"],
});
{
"success": true,
"communities": [{"summary": "...", "entity_count": 14, "relationship_count": 22,
"hierarchy_level": 0, "relevance": 0.7121}],
"count": 3
}
Community summaries are opt-in (graph.community_summaries / graph.communitySummaries, off by default). With them off this action is advertised as off: discover marks it (OFF) and never suggests it, and a call is refused with an error naming the setting.
A community is built and read inside one source. Summaries are withheld (success false, with an error saying why) from a caller who cannot see every chunk in scope, and refused when the host’s scope names documents, since a summary covers a whole source.
Graph answers and masking. Entity names, types, labels, evidence and community summaries come from document text, so every string in a graph answer goes through the redaction policy. The entity ids (id, source_id, target_id) are exempt: they carry no text, and they are the only handle a model can pass back as the next entity when the names are masked. An id the caller cannot see answers entity not found, the same as an unknown name. Rebuilding the graph mints new ids.
Further reading
Cookbooks
Each package ships a runnable multi-tenant RAG recipe: cookbook/multi_tenant_rag.py and js/cookbook/multi_tenant_rag.ts. Three documents share one corpus: an HR policy with acl=["group:hr"], an engineering runbook with acl=["group:eng"], and a holidays page with no ACL. One question is asked four ways, and the recipe asserts the answers: the HR employee sees holidays and the HR policy, the engineer sees holidays and the runbook, an anonymous visitor sees only holidays, and a trusted job sees all three. Each recipe reads DATABASE_URL and embedding settings from the environment, runs migrations and writes documents, so point it at a scratch database. Get the packages from PyPI and npm.
The access-control model
principals has three meanings. TRUSTED disables ACL filtering; [] is anonymous and sees only documents with no ACL; a list sees unrestricted documents plus any whose ACL overlaps it. None is deprecated and means trusted, so never use it for a request with no user: unauthenticated is []. A value that is not a list raises.
Enforcement is in the SQL of every retrieval leg (full-text, trigram, vector and the graph entity filter), before ranking. A document the caller may not see is never fetched, scored or sent to a model. acl IS NULL means unrestricted, and matching is overlap, not containment.
Remote surfaces take principals by injection only, never from a body, query string or header. auth and principals are required on every router factory and on create_mcp_app. A router's principals returning None is rejected; a genuinely trusted mount writes lambda: TRUSTED.
Writes are authorised separately. Filing or patching a document under an ACL the caller does not hold returns 403. A tool with no ACL is refused unless the caller is trusted.
Updating with acl=None unrestricts the document. Leave the argument out to keep the ACL.
The vector index. An ACL on an approximate index is a post-filter that can silently return short. The engine counts eligible rows, scans exactly below StorageConfig.ann_exact_threshold (default 50,000), and above it sizes pgvector's iterative scan to the table. pgvector 0.8 or later is required. context-engine check-acl-exposure measures this on your own data, read-only.
Mistakes worth naming: passing TRUSTED and filtering afterwards (the ranking already reflects documents the caller cannot see), caching a search result across users, and reading principals from the request.
Thresholds follow the query's shape
The trigram leg's similarity threshold moves with the query, from 0.45 for a very short query down to 0.15 for a long one, measured in characters for spaceless scripts (Chinese, Japanese, Thai, Khmer, Burmese, Lao, Tibetan) and in words otherwise. A matching minimum cosine similarity for the vector leg exists but is off by default: search.vector_floor="adaptive" turns it on (0.45 for one or two words, 0.40 for a code, an acronym or an exact-match query, down to 0.25 for a sentence). With it on, the vector leg can return fewer candidates and nothing backfills them. Measure on your own embedding model before enabling it. search.trgm_limit pins the trigram threshold to one value.
Keyword search settings
The full-text leg uses Postgres text search over the simple configuration, which keeps every word. By default (search.lexical_match="all") a chunk must contain every word of the query, stopwords included: precise for keyword queries, but a question such as “what is the notice period for contractors” usually matches nothing. "any" matches a chunk containing any of the query’s words after dropping stopwords (search.lexical_stopwords, the built-in English list by default) and leaves the order to the rank function. Quoted phrases and -term keep their meaning in both modes. For "notice period" contractor -draft, "all" matches chunks with the exact phrase AND the word contractor and without the word draft; "any" matches chunks with the phrase OR the word contractor, still without draft. The simple configuration does not stem, so -draft does not exclude “drafts”. The query is split into words, phrases and terms on ASCII whitespace. search.lexical_rank picks ts_rank_cd (words close together, the default), ts_rank (frequency anywhere) or the experimental, opt-in bm25 (Okapi BM25 in SQL, turned on per database with context-engine bm25-enable, not a default; with search.bm25.k1 1.2, search.bm25.b 0.75 and search.bm25.idf_scope "source", "global" or "document"; its statistics include documents the caller may not see, though no content or id is exposed), and search.lexical_normalization passes Postgres’s 0 to 63 length-normalization bitmask through. Query text is always bound as data, and every variant applies the same scope predicate as the other legs. A leg weighted 0 in fusion.weights is switched off and its query is not sent: the vector leg also skips the query embedding unless compression needs it, and the graph leg is never started, billed as hybrid, and reported as graph_weight_zero. The default weights are 1.0 for the full-text, vector and graph legs and 0.4 for the trigram leg, which the retrieval benchmark measured ahead of 0.8 on four of five public datasets. search.trgm_max_query_words skips the trigram leg for queries longer than that many words, and search.lexical_candidates sets how many candidates the full-text and trigram legs hand to fusion; both are off by default, and measured on five public datasets neither improved ranking everywhere. Fusion uses reciprocal rank fusion with fusion.k 20. The TypeScript names are lexicalMatch, lexicalRank, bm25.idfScope, lexicalNormalization, lexicalStopwords, trgmMaxQueryWords and lexicalCandidates.
Why a document failed
A failed document carries error, the raw exception text for operators, and failure_reason, one fixed code, with failure_message as its fixed sentence. Model-facing doors (this tool's get_doc, get_docs and list, and the routers' document read for non-trusted callers) never return error. A later success clears both.
| failure_reason | failure_message |
|---|
| input_too_large | the file is too large to process in one go. Split it into smaller files and upload those |
| provider_rejected | the embedding provider refused the request. Check the model name and key |
| provider_unavailable | the embedding provider was unavailable. Try again later |
| extraction_failed | the file could not be read |
| chunk_set_changed | the document was re-ingested while it was being processed. Re-ingest or resume |
| model_deferred_expired | the host model did not answer within ingest.max_park_seconds. Re-ingest the document |
| embedding_dimension_mismatch | the embedder returned vectors of a different size than this database stores. Check the embedding model and its dimension |
| unknown | processing failed |
An unrecognised code reads as unknown rather than as no failure.
The ingest usage event
hooks.on_usage is the metering seam; the package never handles billing itself. Every UsageEvent(kind="ingest") carries mode (the mode the document was actually processed at) and mode_reason (why that differs from the request, currently only "tabular documents stay hybrid", else None) in its detail, on every path that emits one. A DOCX is counted by the page count the file reports, with pages_source saying where it came from: app_xml, rendered_breaks, page_breaks or estimate. A declared count is only believed as far as the body could hold it.