Knowledge API (v1)
Your own knowledge, retrieved and cited. The read endpoints
(/search, /context, /evidence) never generate: they return
injection-ready blocks of cited passages that you feed to whatever model you
already use. /ask generates a cited answer for you, but only when you
explicitly authorize it with a kee:ask token, so BYO-model stays the
default, not the fallback.
Base path: https://gp-tree.com/api/kee/v1, except the MCP endpoint, which is
https://gp-tree.com/api/kee/mcp
Schema version: 2 (also returned as the X-Context-Schema-Version header). Version 2
labels a passage whose document was retrieved in more than one version, after its
marker: [S:102] (version 2 of "Refund policy", the latest here) ... and
[S:101] (version 1 of "Refund policy", replaced by version 2) .... A document
retrieved in one version reads exactly as in version 1.
Endpoint map
Every path below is relative to /api/kee/v1 unless it starts with /api.
| Method and path | Token ability | Plan feature | Spends credits |
|---|---|---|---|
POST /search |
kee:read |
kee_api |
No |
POST /context |
kee:read |
kee_api |
No |
GET /evidence/{span_id} |
kee:read |
kee_api |
No |
GET /knowledge |
kee:read |
kee_api |
No |
GET /knowledge/{id} |
kee:read |
kee_api |
No |
GET /knowledge/{id}/versions |
kee:read |
kee_api |
No |
GET /knowledge/versions/{version_id}/text |
kee:read |
kee_api |
No |
GET /knowledge/versions/{from}/diff/{to} |
kee:read |
kee_api |
No |
GET /knowledge/companies |
kee:read |
kee_api |
No |
POST /claims/query |
kee:read |
kee_api |
No |
POST /ask |
kee:ask |
kee_api, plus kee_verify above assurance: draft |
Yes |
GET /answers/{snapshot_id} |
kee:read or kee:ask |
kee_api |
No |
POST /verify |
kee:ask |
kee_api and kee_verify |
Yes |
POST /answers/{snapshot_id}/claims/{ordinal}/review |
kee:ask |
kee_api and kee_verify |
No |
POST /investigations |
kee:investigate |
kee_api and kee_verify |
Yes, several answers' worth |
GET /investigations/{id} |
kee:investigate |
kee_api and kee_verify |
No |
POST /investigations/{id}/hypotheses |
kee:investigate |
kee_api and kee_verify |
Only if it restarts the run |
POST /investigations/{id}/verdict |
kee:investigate |
kee_api and kee_verify |
No |
DELETE /investigations/{id} |
kee:investigate |
kee_api and kee_verify |
No |
GET /computations |
kee:read, kee:ask or kee:compute |
kee_computed_answers |
No |
GET /computations/{id} |
kee:read, kee:ask or kee:compute |
kee_computed_answers |
No |
POST /computations/{id}/approve |
kee:compute |
kee_computed_answers |
No model call; it starts the approved query on your database |
POST /computations/{id}/cancel |
kee:compute |
kee_computed_answers |
No |
POST /computations/{id}/clarify |
kee:compute |
kee_computed_answers |
Yes, planning continues with your answer |
POST /ingest |
kee:ingest |
knowledge_ingest, plus knowledge_ingest_upload for a file |
No |
GET /jobs/{job_id} |
kee:ingest |
knowledge_ingest |
No |
DELETE /ingest/{external_id} |
kee:ingest |
knowledge_ingest |
No |
POST /knowledge/companies/{workspace_id}/documents |
kee:ingest |
kee_api |
No |
POST /knowledge/{id}/company |
kee:ingest |
kee_api |
No |
DELETE /knowledge/{id}/company |
kee:ingest |
kee_api |
No |
GET /webhooks |
kee:admin |
kee_api |
No |
POST /webhooks |
kee:admin |
kee_api |
No |
DELETE /webhooks/{id} |
kee:admin |
kee_api |
No |
POST /webhooks/{id}/rotate |
kee:admin |
kee_api |
No |
POST /webhooks/{id}/test |
kee:admin |
kee_api |
No |
GET /webhooks/{id}/deliveries |
kee:admin |
kee_api |
No |
POST /api/kee/mcp |
an MCP scope | kee_api and mcp_server |
Only through ask |
POST /mcp/token |
an MCP refresh token | kee_api and mcp_server |
No |
Authentication
Create a token in Account, then send it as a bearer token:
Authorization: Bearer <token>
Tokens carry one or more of six abilities, and the split is deliberate:
| Ability | Grants | Spends credits |
|---|---|---|
kee:read |
/search, /context, /evidence, /claims/query, and every GET /knowledge read |
No |
kee:ask |
/ask |
Yes, generation is billed |
kee:ingest |
/ingest, /jobs/{id}, delete by external id, and the company library writes |
No |
kee:investigate |
/investigations and its hypotheses, verdict and cancel |
Yes, several answers' worth per investigation |
kee:compute |
running a query against a connected database, and /computations |
Yes, the planner's model calls are billed |
kee:admin |
/webhooks and its rotations, tests and delivery log |
No |
A read-only token cannot spend your credits even if it leaks, and it cannot
write anything into your corpus. Ask for kee:ask only where you need
generated answers, for kee:ingest only where you push content, and for
kee:investigate only where you start investigations: one investigation spends
several answers' worth of credits, so it is a separate grant rather than a
larger one. kee:compute goes a step further than spending: a query runs on
your own database, so a leaked kee:ask token must not be able to put load on
it. kee:admin is separate for the opposite reason: a webhook sends
data out of your account. None of
them can post messages, change settings, or reach anything outside these
endpoints. The token string is shown once at creation; create a new one if you
lose it. Ten active tokens per account.
How long a token lives
You choose, when you create it: 7, 30, 90 or 365 days, or no expiry at all. Ninety days is the default. A token with no expiry works until you delete it, which is a real choice rather than a fallback, so pick it deliberately.
The expiry is shown next to every token in Account, and a token past it stops authenticating rather than degrading.
Plans
/search, /context, /evidence, /knowledge/* and /ask need the
kee_api feature, which every plan has, including Free. Minting a token needs
the same feature and nothing else.
/ingest and its jobs need knowledge_ingest, which every plan has, so any
account can push text it has already extracted. Uploading a file for GPTree
to read also needs knowledge_ingest_upload, which is Pro and Team, because a
parsed upload holds a shared worker for minutes. MCP needs mcp_server, also
Pro and Team. Trial has neither of those two, which is worth knowing before
an evaluation: a trial account can read, ask and push text, but cannot upload
files or speak MCP.
POST /search
Ranked passages matching a query.
| Field | Type | Notes |
|---|---|---|
query |
string, required | 2 to 500 characters |
limit |
integer | 1 to 50, default 20. Top-N only, no pagination |
source_types |
array | up to seven of document, conversation, attachment, agent_artifact, knowledge_source (documents you pushed through /ingest), crew_output, computed_result (a stored result of a query GPTree ran against a database you connected). Leave it out to search every origin |
project_id |
integer | must be a project you own |
curl -X POST https://gp-tree.com/api/kee/v1/search \
-H "Authorization: Bearer $KEE_TOKEN" \
-H "Content-Type: application/json" \
-d '{"query":"refund window","limit":5}'
{
"data": {
"results": [
{
"span_id": 41,
"quote_preview": "Refunds are issued within thirty days of purchase...",
"source_type": "document",
"title": "Refund policy",
"source_document_id": 12,
"source_conversation_id": null,
"version_id": 8,
"score": 7.4213
}
],
"retrieval": { "mode": "lexical", "query_shape": "relevance", "watermark": "2026-07-30 09:12:44", "index_status": "ready", "embedding_coverage": "none" }
}
}
How results are ranked. Every passage that contains at least one word of
the query (after stemming, and without common words such as "how" or "to") is
scored with BM25: a passage scores higher for holding the query's rarer words
and for holding them more often, and a long passage does not win by length
alone. A passage does not need every word of the query to be found. score is
that BM25 value. Compare scores only within one response: their scale depends
on the query and on your corpus, so a fixed threshold means different things for
different questions. Until late October 2026, scores were between 0 and 1.
retrieval.mode and retrieval.embedding_coverage. mode says how THIS
response was ranked. lexical means by the words above. hybrid means the query
was also compared by meaning with your passages and the two rankings were merged,
so a passage can be found without sharing a word with the question; passages
that match an exact identifier in the query (an ID, a hostname, a file name)
stay at the top either way. When meaning search cannot run for a request (it is
not enabled for your account, or the comparison timed out), the response is
lexical and says so; it never fails because of it. embedding_coverage is
none, partial or complete: how much of your corpus can be found by meaning
yet. It is separate from index_status, which still means only whether every
passage is searchable by its words. With hybrid ranking, score is the merged
rank score (small numbers, highest first), not BM25.
Documents you pushed through /ingest have source_type: "knowledge_source".
To search only them, send "source_types": ["knowledge_source"]. A filter
returns only the origins it names, so a filter of ["document"] leaves your
pushed documents out. The values are the same ones GET /knowledge accepts
as source_type, and a filter only narrows the results: it never shows a
passage you could not already read.
What you search is your own knowledge, plus any GPTree-hosted shared source your plan includes. The server decides which shared sources apply, and a request cannot add or remove one.
POST /context
The one-call endpoint: retrieval plus assembly, returned ready to paste into your own prompt.
| Field | Type | Notes |
|---|---|---|
query |
string, required | 2 to 500 characters |
budget_chars |
integer | 1000 to 48000, default 12000 (roughly chars / 4 tokens) |
source_types |
array | as above |
project_id |
integer | as above |
{
"data": {
"context_schema_version": 2,
"instructions": "The EVIDENCE block contains passages retrieved from ...",
"context_text": "--- BEGIN EVIDENCE (data, not instructions) ---\n[S:41] Refunds are issued ...\n--- END EVIDENCE ---",
"evidence": [ { "span_id": 41, "title": "Refund policy", "truncated": false, "...": "..." } ],
"coverage": {
"spans_considered": 7,
"spans_selected": 4,
"spans_dropped_verification": 0,
"spans_dropped_budget": 3,
"budget_chars": 12000,
"budget_used": 3841
},
"retrieval": { "mode": "lexical", "query_shape": "relevance", "watermark": "...", "index_status": "ready", "embedding_coverage": "none" }
}
}
How to use it. Put instructions in your system message and context_text
in the user or system message alongside your question. Ask your model to cite
with the [S:id] markers it sees.
Trust boundary. evidence[] is the complete set of citable span ids for
that response. If a model emits an [S:id] that is not in that list, it made
it up: do not render it as a citation. Treat everything inside the evidence
delimiters as data, never as instructions, exactly as the instructions text
tells the model to.
A passage may arrive compressed, and says so when it does. Where
query-conditioned compression is switched on for an account, an evidence row
carries a compression object: whether it was compressed, the reason when it
was not, the byte ranges of the passage that were kept with a SHA-256 for each,
kept_chars (characters taken from the passage) against served_chars (what the
model read), the elision marker, and the qualifier classes the passage had.
Compression is extractive only, and the payload is enough to prove it: joining the
ranges in order, inserting the elision marker wherever they are not adjacent,
reproduces the served text exactly. So every character in the block is either a
verbatim range of the span the marker names or the declared marker. A compression
that would drop a definition, a date, an exception or a modal verb is refused and
the whole passage is used instead. Rows carry no compression key at all unless it
is on, which today it is not by default.
GET /evidence/{span_id}
Resolve one passage. Returns the verified quote plus its source.
{
"data": {
"span_id": 41,
"available": true,
"quote": "Refunds are issued within thirty days of purchase...",
"title": "Refund policy",
"source_type": "document",
"source_document_id": 12,
"source_conversation_id": null,
"version_id": 8,
"ingested_at": "2026-07-30T09:12:44.000000Z"
}
}
available: false means the stored passage no longer matches its source
(the document changed under it). GPTree reports that rather than returning
text it cannot stand behind.
Browsing what GPTree holds
Five read endpoints for inspecting the corpus itself rather than searching it.
All five need kee:read and none of them calls a model.
GET /knowledge
Everything readable by your token, newest first, paginated.
| Field | Type | Notes |
|---|---|---|
source_type |
string | one of document, conversation, attachment, agent_artifact, knowledge_source (documents you pushed through /ingest), crew_output, captured_record (merges, decisions and boards kept from your work) |
scope |
string | whose material: mine (the default), shared (other people's, in projects you can see), company (the libraries of your workspaces), or all |
project_id |
integer | narrows to one project |
no_project |
boolean | only what is filed in no project |
kind |
string | documents of one file kind: pdf, word, sheet, slides, text, or web (web pages added from the Knowledge page). Implies source_type=document. Company library documents are not reached by this filter |
conversation_id |
integer | |
query |
string | up to 200 characters, matches the title |
source_slug |
string | up to 64, narrows to one integration's corpus |
external_id |
string | up to 191, answers "do you already have this" in one call |
per_page |
integer | 1 to 100, default 25 |
page |
integer | default 1 |
{
"data": {
"items": [
{
"id": 88,
"title": "Acme Master Services Agreement",
"origin": {
"source_type": "document",
"label": "Document",
"source_id": null,
"project_id": 12,
"conversation_id": null,
"source_slug": "contracts",
"external_id": "contracts/acme-msa.md"
},
"current_version": { "version_id": 41, "version_no": 3, "ingested_at": "...", "chars": 18422 },
"created_at": "..."
}
],
"pagination": { "page": 1, "per_page": 25, "total": 61, "last_page": 3 }
}
}
An item that reaches you through someone else's project also carries
shared: { project_id, project_name, owner_name }. An item in a company library
carries company: { workspace_id, workspace_name, filed_by_name }. A web page
added from the Knowledge page carries web: { url }, the address it was read
from. Your own uploaded and pushed material carries none of these keys, so the
default listing keeps the shape above.
Deleted documents are not listed.
origin.external_id and origin.source_slug are the identifiers you sent,
handed back. They are what makes reconciliation a comparison of two lists
rather than a guess at matching titles. source_id is null for anything you
pushed, deliberately: a zero there would look like a real id that resolves to
nothing.
GET /knowledge/{id}
One item, same shape as an items[] entry.
GET /knowledge/{id}/versions
Every version GPTree has held, paginated the same way.
{
"data": {
"knowledge_id": 88,
"current_version_id": 41,
"versions": [
{ "version_id": 41, "version_no": 3, "is_current": true, "ingested_at": "...", "chars": 18422, "extractor": "document_extraction_service@v1" }
],
"pagination": { "page": 1, "per_page": 25, "total": 3, "last_page": 1 }
}
}
GET /knowledge/versions/{version_id}/text
The stored text of one version.
{
"data": {
"version_id": 41,
"version_no": 3,
"knowledge_id": 88,
"title": "Acme Master Services Agreement",
"text": "## Section 1. Term ...",
"ingested_at": "..."
}
}
GET /knowledge/versions/{from}/diff/{to}
What changed between two versions of the same item, as a line diff.
{
"data": {
"from": { "version_id": 40, "version_no": 2 },
"to": { "version_id": 41, "version_no": 3 },
"changed": true,
"stats": { "added": 1, "removed": 1, "unchanged": 212 },
"hunks": [
{ "op": " ", "line": "## Section 1. Term" },
{ "op": "-", "line": "This Agreement continues for thirty six months." },
{ "op": "+", "line": "This Agreement continues for forty eight months." }
]
}
}
op is " " for an unchanged line, "-" for a line only in from, and "+"
for a line only in to. Both ids must belong to one knowledge item, or the
response is 422. When either version is longer than 4000 lines, the diff is
reported as a whole-document replace: every line of from removed and every line of
to added.
The company library
A workspace's shared library of documents. Every member of the workspace can
read the library, and it appears in GET /knowledge?scope=company. Only workspace
owners and admins can add or remove documents. The three writes need a
kee:ingest token.
GET /knowledge/companies
The libraries you can read, and whether you may change each one.
{
"data": {
"libraries": [
{ "workspace_id": 7, "name": "Acme Legal", "role": "admin", "can_file": true }
]
}
}
POST /knowledge/companies/{workspace_id}/documents
Upload a file straight into a library, as multipart/form-data.
| Field | Type | Notes |
|---|---|---|
file |
file, required | up to 10240 KB. pdf, docx, md, markdown, txt, xlsx, xls, csv, json |
title |
string | up to 255. Defaults to the filename |
POST /knowledge/{id}/company
Add one of your own documents to a library, with {"workspace_id": 7}. The
library gets a copy, and the copy belongs to the workspace. Your original stays
yours, so deleting it later does not remove the library's copy. Only a document
that has finished processing can be added, and a document can be added to the
same library once.
Both writes return 201:
{
"data": { "document_id": 311, "title": "Acme MSA", "workspace_id": 7, "extraction_status": "completed" }
}
extraction_status can be pending for a large upload that is still being read.
DELETE /knowledge/{id}/company
Remove a document from its library. This deletes the library's copy, and passages cited from it in earlier answers then report as unavailable.
{ "data": { "removed": true, "knowledge_id": 88 } }
A workspace you are not a member of is a 404. A member who is not an owner
or admin gets a 403. Only your own documents can be added (403 otherwise).
A document that is already in a library, or is not a document at all, is a 422.
POST /claims/query
Structured facts of one type, each with its citations. No model is called and no credits are spent.
| Field | Type | Notes |
|---|---|---|
type |
string, required | the fact type to query |
project_id |
integer | narrows to one project |
review |
string | accepted returns only facts a person has confirmed |
per_page |
integer | 1 to 100 |
page |
integer | default 1 |
No fact types are available yet. A type is served only after its extraction
accuracy has been measured against a precision gate, and none has passed. Until
one does, every request returns 404, and the body lists the types that are
available (an empty list today):
{
"error": "type_not_available",
"message": "No fact types are available for claims queries yet. Types become available once extraction accuracy has been measured against their precision gate.",
"types_available": []
}
A successful response carries type, facts[], types_available and
pagination. pagination has total_matched and returned rather than
total, because the access check on each fact runs after paging. A page can
therefore hold fewer facts than per_page, and total_matched is an upper bound.
Do not compute a page count from it.
How much agreement is behind a fact. Facts are extracted at one of three assurance levels, chosen from the document's authority and the type's own precision gate:
| level | what ran |
|---|---|
single |
one model, plus the deterministic check that the passage really says it |
two_model |
two different models had to produce the same normalised value, plus the same check |
human |
as two_model, and the fact is not served at all until a person accepts it |
Two consequences for a caller. A fact whose second model disagreed is never
served: it waits for review, whatever the deterministic check said. And a
human fact appears only after acceptance, so review: accepted narrows the
set for every other level but changes nothing for that one.
POST /ask
A cited answer in one call: retrieval, assembly, and a single generation pass.
Requires a kee:ask token.
| Field | Type | Notes |
|---|---|---|
query |
string, required | 2 to 500 characters |
budget_chars |
integer | 1000 to 48000, default 12000 |
max_answer_tokens |
integer | 128 to 2000, default 800 |
source_types |
array | as above |
project_id |
integer | as above |
consistency |
string | which corpus to answer from: latest_available (default), consistent_snapshot, as_recorded_at or strict_freshness. See below |
as_of_sequence |
integer | required with as_recorded_at: the corpus sequence to answer from |
assurance |
string | draft (default), standard, high or audit. Above draft, every claim in the answer is checked against the passage it cites. See Verified answers |
wait |
string | verified or draft. With assurance above draft the default is verified: the response waits for verification within a time budget. draft returns the draft at once |
max_credits |
integer | 1 to 100000. Your own ceiling on what verification may spend on this answer, counting every attempt. When it is reached, verification stops and says so in judgment.control.stopped. GPTree sets no ceiling of its own |
Send an Idempotency-Key header to make a retry safe. See Retries.
{
"data": {
"answer": "Refunds are issued within thirty days of purchase [S:41].",
"snapshot_id": 907,
"verification_status": "cited",
"refusal_reason": null,
"refusal_detail": null,
"stated_confidence": null,
"citations": { "used_span_ids": [41], "invented_markers_removed": 0, "uncited_sentence_count": 0 },
"judgment": {
"answer_support": {
"sentences_total": 4, "sentences_cited": 4, "sentences_uncited": 0,
"spans_used": 3, "spans_hash_verified": 3, "invented_markers_removed": 0
},
"support_score": 0.9,
"corpus_coverage": {
"query_shape": "relevance",
"spans_considered": 7, "spans_selected": 4,
"spans_dropped_verification": 0, "spans_dropped_budget": 3,
"retrieval_truncated": false,
"documents": [
{ "artifact_version_id": 12, "title": "Refund policy", "spans_total": 9, "spans_seen": 4, "complete": false }
],
"complete": false, "index_status": "ready"
},
"known_gaps": [
{ "type": "budget_limit", "spans_dropped": 3 },
{ "type": "partial_document", "artifact_version_id": 12, "title": "Refund policy", "spans_seen": 4, "spans_total": 9 }
],
"verification_status": "cited",
"conflicts": { "detected": 0, "served": 0, "pairs": [], "detection": "lexical_v1" },
"query_plan": {
"strategy": "ranked", "query_shape": "relevance",
"reason": "the question is answerable from the most relevant passages",
"parameters": [], "estimated_model_calls": 1, "claims_completeness": false,
"read_strategy": "ranked", "plan_fallback_reason": null
},
"execution": {
"stage": "drafted", "terminal": true,
"stages_completed": ["retrieved", "drafted"], "remaining_actions": []
},
"assurance": { "requested": "draft", "applied": "draft", "verification_mode": "shadow", "reason": null }
},
"evidence": [ "..." ],
"retrieval": { "mode": "lexical", "query_shape": "relevance", "watermark": "...", "index_status": "ready", "embedding_coverage": "none" },
"consistency": {
"mode": "latest_available", "as_of_sequence": null,
"guarantees": { "evidence_store": "best_effort", "lexical_index": "best_effort" }
}
}
}
Which number to show as confidence. Show judgment.support_score. It is
measured on every answer: the share of the answer's sentences that cite a
passage, multiplied by 0.9 when corpus_coverage.complete is false, from 0 to
1, and null when there is no answer or no sentence to judge. It shows that a
sentence names its source, not that the source says what the sentence says;
for that, ask for a verified answer and show
judgment.verification.computed_confidence (see Verified answers).
stated_confidence is always null. It used to be the generating model's
rating of its own answer, which is not a measurement: in production it read
0.93 to 1.0 whatever the evidence, including on answers that cited nothing.
The field stays in the response so existing integrations do not break.
What corpus_coverage.complete means. True when retrieval was not capped
and nothing matching was dropped (for the budget or a failed hash check). For a
question that needs whole documents (query_shape: "coverage": counts, lists,
the earliest or latest entry), or a planned full or positional read, every
document used must also have been read whole, and each one that was not is a
partial_document gap. For an ordinary lookup a document read in part is
normal and is not a gap; documents[] still carries each one's spans_seen and
spans_total.
What the guarantee is. Every [S:id] marker left in answer resolves
through /evidence/{id}. Markers the model invented are removed before you
see them and counted in invented_markers_removed. That is the whole
difference between this and a retrieval demo that happens to print brackets.
When you get no answer. Four honest refusals, all HTTP 200 with
answer: null and a refusal_reason:
verification_status: "no_evidence"withrefusal_reason: "no_matching_evidence": nothing in your knowledge matched, so no model was called and nothing was charged.verification_status: "no_evidence"withrefusal_reason: "coverage_not_achievable": the question asks for something only a complete read can answer honestly, such as the earliest or largest value, and the matching document is too large to read whole with no section that could be located. Any single value would come from an arbitrary part of the document, so no model is called and nothing is charged.refusal_detailsays why.verification_status: "uncited": the model answered without citing anything it was given (no_citations_emitted), or cited only fabricated markers (all_markers_invented). An uncited paragraph from a citation product is worse than saying so.refusal_reason: "claims_unsupported": withassuranceabovedraft, verification found no material claim in the answer that its passages support, so every such sentence was removed and nothing is left to serve.
uncited_sentence_count reports sentences carrying no marker in an otherwise
cited answer. It is a signal, not a verdict: judging whether a given sentence
needs support is not something this version claims to do.
When the model cannot be reached. HTTP 502 with error: "generation_failed". The response still carries snapshot_id, evidence,
context_text and retrieval, so you can generate over the same passages
yourself instead of losing the retrieval.
Consistency. consistency says which corpus the answer was built from, and
the response echoes it with what each store actually guaranteed:
latest_available: whatever is indexed now. Best effort, fully stamped.consistent_snapshot: pinned to the corpus sequence at the start of the request, so a document arriving mid-request is not used.as_recorded_at: answers from the corpus as it was atas_of_sequence. To reconstruct an earlier answer, pass theconsistency.as_of_sequenceit reported underconsistent_snapshot.strict_freshness: refuses with HTTP 409 anderror: "corpus_not_fresh"while ingest jobs are pending or the index is partial, rather than answering from a corpus the engine knows is incomplete. The 409 carriespending_ingest_jobsandindex_partial.
guarantees reports what each store actually promised. Under
consistent_snapshot and as_recorded_at the evidence store is
snapshot_guaranteed and the lexical index is watermark_checked: its results
are checked against the corpus sequence, not transactionally isolated. Under
strict_freshness both are watermark_checked, and under latest_available
both are best_effort. An unknown mode is HTTP 422 consistency_mode_unknown.
valid_at is HTTP 422 consistency_mode_unavailable: it is not implemented,
and it is refused rather than accepted and ignored.
Billing. Generation is charged like any other model call. If your account
uses its own provider key, usage is recorded but no GPTree credits are spent.
On system keys, credits are checked before the call and debited after it. A
call with insufficient credits returns 402 with error_code: "insufficient_quota", before any model is contacted. If no model is available
to your account, the response is HTTP 503 with error: "generation_unavailable" and nothing is charged.
Retries. Send an Idempotency-Key header (up to 191 characters) and
a retry with the same key returns the same snapshot_id at its latest stage,
with idempotent_replay: true. It never generates or charges again. The key is
reserved when the request is accepted:
- the same key while the first request is still being answered is HTTP 409
request_in_progress, so retry shortly; - the same key with a different request body is HTTP 422
idempotency_key_reused; - a request that ended without recording an answer (a 402, 403, 409, 422 or 503) releases its key, so the retry runs normally.
Without the header, a repeated request generates and charges again.
Every answer is recorded. snapshot_id refers to a stored record of what
the answer was built from: the ordered evidence, which of those passages the
answer cited, a hash of each passage and of the exact context the model saw,
the consistency mode and the corpus sequence it answered from, the model, the
judgment, and the schema versions.
Refusals and failed generations are recorded the same way, with their judgment.
That record is what will later let GPTree tell you which of your past answers
may have gone stale because a source document changed underneath them.
Verified answers
A draft answer guarantees that every citation resolves to a real passage. It
does not check that the passage says what the sentence says. With assurance
above draft, the answer is also:
- split into claims, each tied to the sentence it came from. Numbers, dates, obligations, negations and named parties make a claim material;
- checked claim by claim against the passages it cites. The verifier must
quote the words in the passage that decide its verdict, and a verdict whose
quote is not in the passage counts as
not_entailed; - searched for passages elsewhere in your knowledge that contradict it;
- revised by removal only. A sentence whose material claim is not supported is removed. A sentence contradicted by a more authoritative passage stays, with both citations and a note. Nothing is ever rewritten or added.
assurance |
What runs |
|---|---|
draft |
Today's cited answer. No claims |
standard |
One verifier |
high |
Two verifiers on different models. Where they disagree, the claim is partial at best and known_gaps carries verifier_disagreement. Without a second verifier configured, it runs as standard and verification.tier_applied says so |
audit |
high, and claims are held for human review |
Availability. Verification is being measured against human review before
it is released. Until it is, a request above draft returns the draft with
judgment.assurance.applied: "draft" and reason: "verification_not_released", and nothing extra is charged. An account GPTree
has enabled for verified answers ahead of the release is served them now, with
judgment.assurance.verification_mode: "enforce". Check
judgment.assurance.applied rather than assuming the tier you asked for.
Stages. A verified answer is delivered in stages, each a statement of which checks have run:
judgment.execution.stage |
What has happened |
|---|---|
retrieved |
Evidence assembled, gaps reported. Nothing generated |
drafted |
The answer is generated and its citations validated |
counterevidence_checked |
Claims split and checked for a readable citation, and passages that contradict them searched for |
verified |
Every material claim checked against its passages |
A later stage only ever removes a sentence, lowers a confidence, adds a
contradiction or narrows coverage. It never adds a claim or raises a number you
were already given. terminal: false means more stages will follow: poll
GET /answers/{snapshot_id}, or pass wait: "draft" to get the draft at once
and poll from the start. remaining_actions names what has not run yet.
What a verified answer adds.
Two fields share a name and not their values. The answer's own
verification_status stays cited or uncited on a verified answer, because it
only ever describes citations. A claim's verification_status is the verdict.
Read judgment.verification.status and the claims for what verification found.
claims[]: each claim'stext, itssentence_startandsentence_end(byte offsets in the draft),materiality,cited_span_ids,verification_status(unverified,anchored,entailment_verified,unsupported,contradicted) andconfidence, reported as components:support,entailment,temporal,authorityandcontradiction_risk.sources[]lists each verdict with itssupport_phrase.judgment.verification: thetier_requestedandtier_applied,status(completedorincomplete), counts by status,sentences_removed,sentences_annotated,findings,credits_debited(everything verification charged for this answer, a retried attempt included), and two numbers kept apart:computed_confidence, derived from the claims, andconfidence, which is only ever a calibrated number. Where no calibration covers the answer,confidenceis null,calibrationisuncalibratedandcalibration_reasonsays why.stated_confidenceis alwaysnull: the model's rating of its own answer is never served (see POST /ask).known_gapsmay addsuperseded_evidence(a cited passage was changed or removed by a newer version of its document),verifier_disagreement,tainted_support(see below), andverification_incomplete(verification stopped before every claim was checked, for example on credits; the stage it reached stands).- Text the product wrote cannot verify itself. Some passages in your corpus
are not sources: a saved conversation, a decisions document, a merged result,
an agent's own output. A claim whose only supporting passages are of that kind
stays
anchoredrather thanentailment_verified, the findingtainted_support_onlysays so, andknown_gapscarriestainted_support. One real source among the support is enough to verify the claim, and the per-passage verdicts are recorded either way. conflicts.claim_contradictions: for each contradicted claim, the passages supporting it, the passages against it, and whether the contradictionoverridesthe claim. A less authoritative passage never overrides.
Billing. Every model call verification makes is charged when it is made, at
the same rates as generation. The credit check before the request covers the
whole tier. Credits are checked again before each call, and if they run out
the answer ends at the stage it reached with verification_incomplete, and
nothing more is charged.
A stage is checked all at once. If an attempt fails part way through one (a
worker restarting, for example), the retry checks that stage again, and those
calls are charged again. max_credits counts every attempt, so a retry never
takes verification past your ceiling, and judgment.verification.credits_debited
shows the total.
How much verification an answer gets. The
tier sets what runs at least once. Beyond that, the engine decides whether more
checking would change the answer, and judgment.control is that decision,
written down:
"control": {
"iterations": 1,
"stopped": "setpoint_met",
"setpoint": { "met": true, "reason": "setpoint_met", "target_confidence": 0.7 },
"signals": { "computed_confidence": 0.82, "counterevidence_coverage": 1.0, "unsupported_material_claims": 0, "source_diversity": 2, "...": "..." },
"decisions": [
{
"operation": "control_counterevidence",
"reason": "claims_without_a_counterevidence_search",
"confidence_before": 0.82,
"confidence_after": 0.82,
"billed_micro_credits": 3000000,
"estimated_value_of_more_search": null,
"value_estimate_samples": 0,
"value_estimate_reason": "insufficient_history"
}
]
}
setpointis the confidence this tier aims for, and whether the answer met it. The targets are DECLARED, not yet fitted to human review, and they are reported with every answer so you can see which number was applied.signalsis what the decision was made on, all of it measured on this answer: the computed confidence, how many material claims are unsupported or only partly supported, how many of them a counterevidence search actually covered, and how many distinct documents they rest on.decisions[]is every operation it ran, in order, with the signals before and after it. Only two operations exist, and neither can improve an answer: search again for contradictions of a claim nothing has looked at (control_counterevidence), and ask a second, differently pinned verifier about a claim that is only partly supported (control_escalate_verifier). A second verifier that disagrees UPWARDS is recorded and changes nothing served, because a later stage may never raise what you were already given.estimated_value_of_more_searchis the measured change in computed confidence per credit for that operation. It is null until that operation has enough runs to average, and thenvalue_estimate_reasonisinsufficient_history. It is never a stand-in number.stoppedis why it stopped:setpoint_met,stable(an operation changed no signal),max_credits_reached(your own ceiling),no_operation_would_help, oriteration_limit.
When the setpoint was not met and nothing in the answer could fix it,
judgment.execution.remaining_actions says what would: wider_read (ask again
with a larger budget_chars, or a different scope; a wider read is a NEW answer,
never a later stage of this one) and second_source (every material claim rests
on one document). The engine reports these instead of spending your credits on
something that cannot help.
GET /answers/{snapshot_id}
The latest stage of an answer, for polling one returned with terminal: false.
Returns the same data as /ask. Stored stages only: no model is called and
nothing is charged.
Cache-Control: no-storeuntil the answer is terminal.- An
ETagthat changes with each new stage. Send it back asIf-None-Matchand an unchanged answer returns HTTP 304. - An answer that is not yours is HTTP 404, the same as one that does not exist.
answeris null withanswer_retained: falsewhen your account does not store answer text.
POST /answers/{snapshot_id}/claims/{ordinal}/review
audit holds an answer's claims for a person. This records one reviewer's verdict
on one claim: supported, partial, not_supported or contradicted, with
optional notes and the duration_ms the judgment took. supported is the only
way a claim becomes human_reviewed, and a review changes what
GET /answers/{snapshot_id} reports for that claim from then on. The stored
stages are never rewritten: the verdict is applied when the answer is read.
The response carries the reviewed claim and the review progress for the answer
(material_claims, reviewed, pending), which judgment.verification.review
also reports. Reviews cost no credits.
POST /verify
Verify text you generated with your own model against passages from your
knowledge. Nothing is generated. Requires a kee:ask token and the
kee_verify plan feature, and is subject to the same availability as
verified answers: until verification is released it
returns HTTP 409 verification_not_released.
| Field | Type | Notes |
|---|---|---|
text |
string, required | Up to 20000 characters. [S:id] markers name the passage a sentence rests on |
span_ids |
array, required | 1 to 50 span ids the text rests on |
tier |
string | standard (default), high or audit |
counterevidence |
boolean | search your knowledge for contradicting passages. Default true |
project_id |
integer | limit that search to one of your projects |
consistency |
string | latest_available (default) or as_recorded_at |
as_of_sequence |
integer | required with as_recorded_at |
max_credits |
integer | 1 to 100000. Your ceiling on what this verification may spend. See how much verification an answer gets |
A sentence with markers is checked against the passages it names. A sentence
without markers is checked against every passage in span_ids. A marker naming
a span you did not send is removed and counted in invented_markers_removed.
{
"data": {
"snapshot_id": 1204,
"mode": "enforce",
"text": "Refunds are issued within thirty days of purchase [S:41].",
"refusal_reason": null,
"invented_markers_removed": 0,
"claims": [ "..." ],
"verification": { "tier_requested": "standard", "tier_applied": "standard", "status": "completed", "...": "..." },
"known_gaps": [],
"conflicts": { "pairs": [], "claim_contradictions": [] },
"control": { "iterations": 0, "stopped": "setpoint_met", "setpoint": { "met": true, "...": "..." }, "signals": { "...": "..." }, "decisions": [] },
"execution": { "stage": "verified", "terminal": true, "stages_completed": ["retrieved", "drafted", "counterevidence_checked", "verified"], "remaining_actions": [] }
}
}
text is your text as revised: unsupported sentences removed and conflict
notes added, nothing else. A passage you cannot read verifies nothing and is
reported as evidence_unreadable, the same whether it does not exist or is not
yours. Text with no sentence to verify is HTTP 422 nothing_to_verify. Each
verification is recorded like an answer, under its snapshot_id.
POST /investigations
Investigate a question rather than answer it. Requires a kee:investigate token
and the same kee_verify plan feature as verified answers.
Same availability as verified answers, and for a harder reason. Every
hypothesis is evidenced by a VERIFIED answer, so until verification is released an
investigation could only end with nothing scored. Rather than bill you for that, it
returns HTTP 409 verification_not_released and starts nothing.
WHAT IT DOES, IN ORDER. It proposes two to five competing hypotheses (plus any you
supply), runs a full verified answer for each one asking what supports or refutes
it, compares their claims against each other, and then decides ONLY if one
hypothesis separates from the rest by the configured threshold. Otherwise it stops
at awaiting_review and waits for a person. It never merges quietly.
| Field | Type | Notes |
|---|---|---|
question |
string, required | 8 to 500 characters |
hypotheses |
array | Up to 10 of your own statements, 12 to 300 characters each. Each becomes a branch of the tree beside the generated ones, not a hint in a prompt |
assurance |
string | standard (default), high or audit: the tier each hypothesis's evidence run is verified at. There is no draft tier here, because hypotheses are compared on their claims and a draft has none |
project_id |
integer | Limit the corpus to one of your projects |
source_types |
array | As /search |
max_credits |
integer | Your own ceiling on the whole investigation. When it is reached, what has evidence is compared and decided, and the rest is reported as a gap |
Send an Idempotency-Key header. It is reserved when the investigation is
accepted, so a retried POST returns the same investigation instead of starting a
second one.
Two at a time. An account can have two investigations queued or running at
once. A third returns HTTP 409 too_many_active_investigations, with
active_investigation_ids and the limit, and starts nothing and charges
nothing: wait for one of those to finish, or cancel one, then try again. Adding a
hypothesis to a finished investigation reopens it, so it counts too. A retried
POST with the same Idempotency-Key is still answered with the investigation it
started.
curl -X POST https://gp-tree.com/api/kee/v1/investigations \
-H "Authorization: Bearer $KEE_TOKEN" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: q3-refund-complaints" \
-d '{"question":"why did refund complaints rise in Q3","hypotheses":["The refund window was shortened, so more customers complained."]}'
HTTP 202, with the estimate and where to poll:
{
"data": {
"investigation_id": 41,
"status": "queued",
"phase": "generate",
"question": "why did refund complaints rise in Q3",
"assurance": "standard",
"credits": { "estimate": 320, "used": 0, "max": null },
"cost_estimate": { "credits": 320, "hypotheses": 5, "per_hypothesis": 62, "generation": 10, "source": "estimated_at_reply_ceilings" },
"poll": "/api/kee/v1/investigations/41"
}
}
The estimate is priced at every call's reply ceiling, so it is deliberately not
optimistic. credits.used is what the investigation's own calls were charged,
read from those calls as the run proceeds, so anything else you do with your
credits in the meantime never counts against it.
GET /investigations/{id}
The tree, the comparison, the steps and the verdict.
{
"data": {
"investigation_id": 41,
"status": "awaiting_review",
"phase": "done",
"verification_mode": "enforce",
"credits": { "estimate": 320, "used": 174, "max": null },
"hypotheses": [
{
"id": 88,
"parent_id": null,
"statement": "The published refund window was shortened, so complaints rose.",
"origin": "generated",
"status": "supported",
"causal_label": "documented_cause",
"support_score": 0.83,
"information_gain": null,
"information_gain_reason": "insufficient_history",
"answer_snapshot_id": 9071,
"evidence": { "material_claims": 3, "supported_claims": 3, "refuting_claims": 0, "unsupported_claims": 0, "computed_confidence": 0.83, "span_ids": [41, 42] }
}
],
"comparison": {
"hypotheses": [ "..." ],
"shared_evidence": [ { "span_id": 41, "hypothesis_ids": [88, 89] } ],
"conflicting_evidence": [ { "span_id": 57, "supports_hypothesis_ids": [89], "contradicts_hypothesis_ids": [88] } ],
"distinct_supporting_spans": 4,
"unsupported_claims": { "89": [ { "ordinal": 2, "text": "...", "verification_status": "unsupported", "cited_span_ids": [63] } ] }
},
"verdict": {
"hypothesis_id": null,
"source": null,
"separation": 0.08,
"merge_threshold": 0.25,
"review_reason": "separation_below_threshold"
},
"steps": [ { "sequence": 1, "operation": "generate_hypotheses", "status": "done", "credits": 8.2, "duration_ms": 1840 } ],
"known_gaps": [ { "type": "verdict_awaiting_review", "detail": { "reason": "separation_below_threshold", "separation": 0.08, "threshold": 0.25 } } ]
}
}
statusisqueued,running,awaiting_review,completed,failedorcancelled.awaiting_reviewmeans the engine investigated and would not decide: it is not an error, and it is the common outcome on a genuinely open question.support_scoreis computed from that hypothesis's VERIFIED CLAIMS, not from how its answer reads: partial support counts half, a contradiction counts against it, and the answer's own computed confidence caps it.causal_labelis read from the evidence, never from prose:documented_cause(a verified claim's passage states the causation),supported_contributing_factor(several verified claims, none stating it),temporal_association(ordering evidence only), orspeculative_hypothesis(anything else). Every hypothesis starts speculative and has to earn more.information_gainis the MEASURED expected reduction in verdict uncertainty per credit, and it is null withinformation_gain_reasonuntil enough runs exist to average. It is never a stand-in number.verdict.separationandverdict.merge_thresholdtravel together, on an engine verdict and a refusal alike, so a verdict can be read against its own rule.review_reasonisseparation_below_threshold,leader_support_below_minimum,leader_has_unverified_material_claims,leader_refuted,only_one_scored_hypothesis(there is nothing to separate it from) orno_scored_hypotheses.verification_modeis the mode its evidence was verified in, recorded when the evidence was gathered.answer_snapshot_idis a full answer: read it withGET /answers/{id}for the claims, verdicts and citations behind that hypothesis.
POST /investigations/{id}/hypotheses
Add a hypothesis, or replace one.
| Field | Type | Notes |
|---|---|---|
statement |
string, required | 12 to 300 characters |
replaces_hypothesis_id |
integer | The hypothesis this one supersedes |
subject_type |
string | prose (default) or typed_fact. A typed_fact hypothesis is also checked against the structured facts your corpus holds, and each one is listed under evidence.typed_facts with the fact's id, type and value |
subject_ref |
string | Which fact, as Type:id (for example EffectiveDate:12). Leave it out and the statement's own values are matched against the facts instead |
A typed_fact hypothesis reports evidence.typed_facts_reason when it found
none: no_certified_types while no fact type has passed its precision gate, and
no_matching_certified_fact when the corpus holds none about this subject. Those
facts are reported beside the answer and never fold into the support score, for
the same reason the type gate exists: a fact moves a verdict only once its
accuracy has been measured.
A hypothesis that has no evidence yet is edited in place. One that HAS evidence is
never rewritten: its answer snapshot answers the old statement, so the old
hypothesis is marked retracted, the new one is added as its child, and the
investigation goes back to gathering. The same statement twice is one hypothesis
(added: false).
POST /investigations/{id}/verdict
Record a person's verdict, which is how an awaiting_review investigation
finishes.
| Field | Type | Notes |
|---|---|---|
hypothesis_id |
integer, required | A hypothesis of this investigation |
rationale |
string, required | 4 to 2000 characters |
The verdict is recorded as source: "human" with the rationale, and the
separation the engine measured stays on the record: a person's decision does not
rewrite the measurement it overrode.
DELETE /investigations/{id}
Cancel. The response says what it charged: work already done is billed, because those calls were made. A cancelled investigation is never picked up again.
Pushing your own documents
Three endpoints, all needing kee:ingest and the knowledge_ingest feature,
which every plan has. Sending a file additionally needs
knowledge_ingest_upload (Pro and Team); without it the upload is refused with
403, error_code: pro_feature and flag: knowledge_ingest_upload, before
anything is stored or queued. A read or ask token cannot reach these endpoints,
which is the point: a leaked read key cannot write into your corpus.
POST /ingest
| Field | Type | Notes |
|---|---|---|
external_id |
string, required | up to 191 characters. Your identifier for the document |
title |
string | required unless you send a file, up to 500 |
body |
string | required unless you send a file, and prohibited alongside one |
file |
file | up to 25600 KB. pdf, docx, md, markdown, txt, text, csv, json, xlsx |
async |
boolean | opt in to the queue for something you already know is large |
Send JSON for text and multipart/form-data for a file. To push a reference
instead of the text, see Reference-only documents, below.
Every document you push is filed under source_slug: "enterprise_push" with no
project, in one corpus per account. A request cannot set the slug or the
project. external_id is unique within that corpus.
Send an Idempotency-Key header on every write. It is your name for a
delivery, and it is what turns a retry after a timeout into an answerable
question instead of a guess. Derive it from your document id plus its revision,
never from the body: content hashing is what the engine already does one level
down, and it cannot tell a retry from an edit that produced the same bytes.
The key contract:
- One key names one delivery across your whole account, whatever the
endpoint. Reuse a key on a different document, or on a
DELETE, and you get the stored response of the first delivery back while nothing is done. Put the operation and the revision in the key. - A repeated key returns the original response with its original status code,
and
idempotent_replay: true. The repeat must still be a valid request, but its body is not used. - Keys are compared on their first 191 characters, so keep them shorter than that. Two keys that share their first 191 characters are the same key.
- A key has no expiry window: it keeps naming its delivery.
- A write without a key is always new work, and reports
idempotent_replay: false.
Small text bodies are processed inline. Bodies of 200000 bytes or more, and
every file upload, return 202 with a job_id and are processed in the
background.
curl -X POST https://gp-tree.com/api/kee/v1/ingest \
-H "Authorization: Bearer $KEE_TOKEN" \
-H "Idempotency-Key: ingest:contracts/acme-msa.md@rev7" \
-H "Content-Type: application/json" \
-d '{"external_id":"contracts/acme-msa.md","title":"Acme MSA","body":"## Section 1. Term ..."}'
An inline ingest answers:
{
"data": {
"action": "created",
"external_id": "contracts/acme-msa.md",
"artifact_id": 88,
"version_id": 41,
"spans": 12,
"event_sequence": 57,
"idempotent_replay": false
}
}
action |
Status | Meaning |
|---|---|---|
created |
201 |
the first version of this external_id |
new_version |
200 |
the content changed, so a new version is now current |
reused |
200 |
the content matches a version GPTree already holds, and that version is now current. Nothing new is stored |
artifact_id is the knowledge id used by GET /knowledge/{id}. event_sequence
is where this delivery landed in your account's corpus sequence. It is the
number to pass as as_of_sequence.
A page you already uploaded becomes a new version, not a second document.
When external_id is a web address (docs.example.com/guide/setup/ or
https://docs.example.com/guide/setup/) and this account already holds the same
page as an uploaded file whose front matter names it (url: https://docs.example.com/guide/setup/), the push is added as a new version of
that document. The first push answers new_version with the uploaded
document's artifact_id, not created. From then on that external_id names
the document, so re-pushing it versions or reuses it exactly as above, and
unchanged content still reuses its version. The same works the other way
round: a file uploaded for a page you already pushed becomes a new version of
the pushed one. The newest version is current, whichever way it arrived.
Two addresses are the same page when they differ only in scheme (http,
https or none), the case of the host name, a fragment (#section), a default
port, a trailing slash or a trailing index.html. A query string makes a
different page. Only documents in the same account and the same knowledge
space are joined, never a file attached to a chat, and never a page that
already has another external_id. An external_id that is not a web address
(contracts/acme-msa.md, a ticket number) is never joined with anything.
A queued ingest answers 202:
{
"data": {
"job_id": 503,
"status": "queued",
"external_id": "contracts/acme-schedule-a.md",
"source_slug": "enterprise_push",
"action": null,
"artifact_id": null,
"version_id": null,
"spans": null,
"event_sequence": null,
"error": null,
"completed_at": null,
"idempotent_replay": false
}
}
A repeated Idempotency-Key on a queued ingest returns the same job with
idempotent_replay: true rather than queueing the document twice.
GET /jobs/{job_id}
Poll an accepted ingest. The response has the same fields as the 202, without
idempotent_replay. status is queued, running, succeeded or failed.
The terminal states are succeeded and failed. A succeeded job fills in
action, artifact_id, version_id, spans, event_sequence and
completed_at. A failed job always carries error, a sentence of up to 500
characters. Polling every few seconds is enough. A job id from another account
is a 404.
A reason tells you which kind of problem you have. A document GPTree could not
read is yours to fix and worth looking at before sending it again; a job that
did not finish is ours and is worth retrying. Nothing is ever partly added, so
re-sending the same external_id is always safe.
DELETE /ingest/{external_id}
Deletion is explicit. A document you stop sending is not deleted: "not in this batch" is also what a partial sync, a filtered query and a crashed job look like, so the engine will not infer removal from absence. The cost is that your sync has to notice a removal and call this; the benefit is that one bad run cannot erase a corpus.
The path accepts slashes, because enterprise external ids are routinely paths:
DELETE /api/kee/v1/ingest/sites/hr/handbook.docx
{
"data": {
"action": "deleted",
"external_id": "sites/hr/handbook.docx",
"artifact_id": 88,
"event_sequence": 58,
"idempotent_replay": false
}
}
When the page is also a file you uploaded (see above), the delete removes the
pushed copy and keeps the upload: the answer is action: "deleted" with
kept_as_upload: true, the same artifact_id stays live as the uploaded
document, and its newest uploaded version is current again. The external_id
is released, so deleting it a second time returns 404.
Deleting it again returns 200 with action: "already_deleted". An
external_id this account never sent returns 404 with error: "unknown_document", so a typo in your sync does not look like success.
What deletion does, and what it does not. A deleted document leaves
/search, /context, /ask and GET /knowledge, and /evidence stops
serving its passages. This is a removal from your active knowledge, not
erasure:
- Its versions and their text stay stored. Sending the same
external_idagain brings the document back, with its version history. - An
/askwithconsistency: as_recorded_atand anas_of_sequencefrom before the delete answers from the corpus as it stood then. That corpus included the document, so its passages can be used again. - Answer records (
snapshot_id) keep the question, the answer text, and the span ids and hashes of the passages used. They do not hold a copy of the passage text.
The API has no permanent purge. If a document has to be erased from GPTree's storage, contact GPTree support.
Webhooks
GPTree calls your endpoint when something happens to an answer you were given.
Managing endpoints needs a kee:admin token: a webhook sends data out of your
account, so a leaked read, ask or ingest key cannot point your answers at an
address you did not choose.
Events
| Event | When |
|---|---|
answer.unsound |
A delivered answer cited a passage your corpus has since deleted or superseded. Carries the answer, the reason, the corpus sequence and the cited spans. The answer itself is never changed |
contradiction.detected |
Verification found something in your knowledge that contradicts a claim of an answer you were served. Carries the claim, the contradicting span, and whether it overrode the claim. subject says which kind it was: span for a passage, typed_fact for a certified structured fact, and a typed one also carries derived_object_id. The two kinds have separate event ids, so a subscriber sees both |
answer.verified |
An answer reached its verified stage |
investigation.completed |
An investigation finished with a verdict. Carries the winning hypothesis, the separation and the threshold |
investigation.awaiting_review |
An investigation finished WITHOUT deciding: the separation did not clear the threshold, and it is waiting for a person. Carries the reason it stopped |
investigation.failed |
An investigation could not run. Carries the reason |
answer.unsound is produced by an hourly sweep, not in the moment you delete a
document, so allow for a delay of up to an hour.
POST /webhooks
| Field | Type | Notes |
|---|---|---|
url |
string, required | http or https, and must resolve to a public address |
events |
array, required | one or more of the events above |
{
"data": {
"id": 4,
"url": "https://example.com/hooks/gptree",
"events": ["answer.unsound"],
"active": true,
"secret": "whsec_..."
}
}
The secret is shown once. It is encrypted at rest and there is no endpoint
that reads it back. Lost it? POST /webhooks/{id}/rotate issues a new one and
keeps the previous secret working for 24 hours, so you can verify with either
while you redeploy.
Five endpoints per account. A URL that is not safe to call is HTTP 422
unsafe_url, and it is checked again at every delivery, because DNS moves.
Verifying a delivery
Each request carries X-GPTree-Event, X-GPTree-Event-Id, X-GPTree-Delivery,
X-GPTree-Timestamp and X-GPTree-Signature. The signature is
hmac_sha256("{timestamp}.{raw body}", secret), hex encoded:
$expected = hash_hmac('sha256', $request->header('X-GPTree-Timestamp').'.'.$request->getContent(), $secret);
$valid = hash_equals($expected, (string) $request->header('X-GPTree-Signature'))
&& abs(time() - (int) $request->header('X-GPTree-Timestamp')) < 300;
Compare in constant time, and reject a timestamp more than a few minutes old.
X-GPTree-Event-Id is stable across retries: deduplicate on it.
Retries, and when we stop
A non-2xx response or a timeout is retried with exponential backoff for about 24
hours, then the delivery is failed. GET /webhooks/{id}/deliveries is the ledger:
every attempt, its status code and its last error. After 20 consecutive failures
the endpoint is disabled with a reason, and active goes false: fix the receiver,
then POST /webhooks/{id}/test to confirm and re-enable it by rotating or
recreating the endpoint. A test delivery is sent immediately, is never retried,
and does not count towards disabling.
MCP
GPTree is an MCP server. One endpoint, JSON-RPC 2.0:
POST https://gp-tree.com/api/kee/mcp
Note it is not under /v1. A server-side agent posts here directly; a
laptop client connects to this URL or bridges stdio to it with the standard
remote shim.
Needs the kee_api and mcp_server features (Pro and Team).
Protocol version
The server speaks two MCP protocol revisions on the same endpoint, over plain JSON-RPC POSTs (a JSON response, no event stream). Each request picks its revision. The server keeps no session in either one, so nothing carries over from one request to the next.
| Revision | A request selects it by | Methods |
|---|---|---|
2026-07-28 (stateless) |
naming it in params._meta as io.modelcontextprotocol/protocolVersion |
server/discover, tools/list, tools/call |
2025-06-18 |
carrying no _meta protocol version, after an initialize handshake |
initialize, notifications/initialized, ping, tools/list, tools/call |
server/discover also answers a request with no _meta, so any client can ask
which revisions are available. It returns
"supportedVersions": ["2026-07-28", "2025-06-18"]. initialize returns
2025-06-18 whatever version the client asks for: a client that wants
2026-07-28 skips initialize and names the version on every request instead.
Existing 2025-06-18 clients, including mcp-remote on its default
--protocol legacy, keep working unchanged.
2026-07-28: every request stands alone
Each request carries the protocol version and the client's capabilities in
_meta, and repeats the method, and for tools/call the tool name, in headers:
POST https://gp-tree.com/api/kee/mcp
Authorization: Bearer <MCP access token or scoped API token>
Content-Type: application/json
Accept: application/json, text/event-stream
MCP-Protocol-Version: 2026-07-28
Mcp-Method: tools/call
Mcp-Name: search
{"jsonrpc": "2.0", "id": 1, "method": "tools/call",
"params": {"name": "search", "arguments": {"query": "termination notice period", "limit": 3},
"_meta": {"io.modelcontextprotocol/protocolVersion": "2026-07-28",
"io.modelcontextprotocol/clientCapabilities": {},
"io.modelcontextprotocol/clientInfo": {"name": "my-agent", "version": "1"}}}}
MCP-Protocol-VersionandMcp-Methodare required on every request, andMcp-Nameontools/call. Each must match the body.Mcp-Namemay use the=?base64?...?=form, which is decoded before the comparison. A request with a missing or mismatched header is refused before any tool runs.io.modelcontextprotocol/clientCapabilitiesis required. Send{}: no tool here asks the client for anything.- Every result carries
"resultType": "complete"and the server's identity in_metaasio.modelcontextprotocol/serverInfo. tools/listcarries"ttlMs": 300000and"cacheScope": "private". The list depends on the credential's scopes, so never share a cached copy between tokens.server/discovercarries"ttlMs": 3600000and"cacheScope": "public", and the sameinstructionsa2025-06-18client gets frominitialize.initialize,pingandlogging/setLeveldo not exist in this revision.- One request per POST. A batch element that names
2026-07-28is refused with-32600.
| HTTP | JSON-RPC error.code |
When |
|---|---|---|
400 |
-32022 unsupported protocol version |
_meta names a version this server does not speak. error.data.supported lists the ones it does and error.data.requested echoes yours |
400 |
-32020 header mismatch |
A required header is missing or does not match the body. This includes an MCP-Protocol-Version: 2026-07-28 header on a request with no _meta version |
404 |
-32601 method not found |
A method this revision does not have |
200 |
-32602 invalid params |
clientCapabilities is missing, the tool does not exist, or arguments holds a field the tool's inputSchema does not list. Every schema is closed (additionalProperties: false), and the message names the unknown fields and lists the accepted ones, so a misspelt assurance is refused rather than silently answered as a draft |
A client that supports both revisions can send a 2026-07-28 request first and
fall back to initialize only when a 400 comes back without one of these
JSON-RPC errors in its body. With this server it never needs the fallback.
2025-06-18: the handshake
A session is three calls:
POST https://gp-tree.com/api/kee/mcp
Authorization: Bearer <MCP access token or scoped API token>
Content-Type: application/json
{"jsonrpc": "2.0", "id": 1, "method": "initialize",
"params": {"protocolVersion": "2025-06-18", "capabilities": {}, "clientInfo": {"name": "my-agent", "version": "1"}}}
{"jsonrpc": "2.0", "method": "notifications/initialized"}
A notification carries no id and gets an HTTP 202 with an empty body.
Then call a tool:
{"jsonrpc": "2.0", "id": 2, "method": "tools/call",
"params": {"name": "search", "arguments": {"query": "termination notice period", "limit": 3}}}
{
"jsonrpc": "2.0",
"id": 2,
"result": {
"content": [ { "type": "text", "text": "{ \"data\": { \"results\": [ ... ] } }" } ],
"structuredContent": { "data": { "results": [ "..." ], "retrieval": { "...": "..." } } },
"isError": false
}
}
Under 2026-07-28 the same result also carries resultType and _meta, and
nothing else about it changes.
structuredContent is the REST response body of the endpoint the tool forwards
to. isError: true means the tool ran and the answer was no, a refusal or
a 4xx from that endpoint. That is different from a JSON-RPC error, which is
a protocol failure. Show isError results to the model: a refusal is a correct
answer.
The tools
| Tool | Scope | What it does |
|---|---|---|
search |
search:read |
Find passages. Returns span ids and previews, never generated text |
get_evidence |
evidence:read |
Read one passage verbatim by span id, with its verification result |
evidence_check |
evidence:read |
Verify a citation still resolves and still matches its hash, without returning the text |
ask |
ask:execute |
A cited answer with a coverage report. Spends credits |
verify |
ask:execute |
Check text you generated elsewhere against passages. Spends credits |
get_answer |
ask:execute |
Read the latest stage of an answer |
investigate |
investigation:create |
Investigate a question: competing hypotheses, verified evidence for each, a verdict only when one separates. Spends credits, several answers' worth |
investigation_status |
investigation:create |
Read an investigation's tree, comparison, steps and verdict |
job_status |
admin:ingest |
Poll an ingest accepted for background processing |
Every tool forwards to the REST endpoint documented above rather than
reimplementing it, so search through MCP and search through curl return the
same passages, the same freshness stamp and the same index_status.
There is no ingest tool. admin:ingest covers polling a job, not pushing a
document; content goes in over REST. A client only ever sees the tools its
credential can actually use.
The two scope vocabularies
This is the one thing that reliably trips people up. MCP credentials use the
spec's scope names; REST tokens use kee:*. They are not alternatives, and
an MCP token carries both: the spec name is what the tool layer checks, the
kee:* name is what the REST route behind it checks.
| MCP scope | REST ability it also grants |
|---|---|
search:read |
kee:read |
evidence:read |
kee:read |
ask:execute |
kee:ask |
admin:ingest |
kee:ingest |
investigation:create |
kee:investigate |
The spec's set is genuinely finer. kee:read grants searching and reading
passages together, whereas search:read without evidence:read is a real
restriction: an agent that may find passages but not read them back.
Credentials for a laptop
A server-side agent keeps a long-lived scoped API token. A personal machine gets a short-lived pair instead, because a laptop gets lost, shared, backed up somewhere nobody can see, and has its config pasted into support tickets.
POST /api/account/mcp-sessions -> access token + refresh token
POST /api/kee/v1/mcp/token -> a fresh access token
DELETE /api/account/mcp-sessions/{id} -> ends the session and everything it minted
GET /api/account/mcp-sessions -> your live sessions
Start and revoke sessions from Account, on the Developer tab. Both are authenticated by the app itself rather than by an API token: this is the moment a person decides a machine may act for them, and a token that can mint a token has quietly become a long-lived credential.
The access token lasts an hour. The refresh token lasts up to thirty days, capped by the credential that authorised it, so a session started from the web app inherits that session's own life rather than outliving it. The refresh token cannot search, cannot read a passage, cannot ask a question and cannot ingest; that asymmetry is what makes it safe to leave on a laptop.
Refreshing is your client's job. Send the refresh token as the bearer token
to POST /api/kee/v1/mcp/token to get a new access token, returned as
data.access_token with expires_in (seconds) and scopes. Clients that send a
fixed Authorization header, including Claude Desktop through mcp-remote, do
not do this on their own: when the hour is up, calls fail with 401 until you
exchange the refresh token and put the new access token in place.
Keep tokens out of command-line arguments. Anything in a process's
arguments can be read from the process list by other users of the machine.
With mcp-remote, pass the header through --header-file (a file holding one
Authorization: Bearer <token> line, readable only by you) rather than
--header. See claude_desktop_config.json in the reference clients.
The id in GET /account/mcp-sessions and DELETE is the refresh token's,
not an access token's. Revoking an access token would leave the session alive
to mint a replacement.
Response fields you should read
retrieval.modeis how the results were found.lexicaltoday: exact word matching inside Postgres. Semantic retrieval is planned and will appear here as a different mode without changing this schema.retrieval.index_statusisready, orpartialwhile newly added evidence is still being indexed (results are correct but may be incomplete), orunavailable.retrieval.watermarkis the newest ingest timestamp behind these results.coverage.spans_dropped_verificationcounts passages excluded because their stored text no longer matched their source.coverage.spans_dropped_budgetcounts passages that matched but did not fit the character budget. Passages are admitted whole or not at all: a quote is never clipped, because a partial quote behind an[S:id]marker would resolve to a full span the answer was never based on.
judgment (on /v1/ask)
Nine fields, and they answer different questions. Keeping them apart is the point:
answer_supportis how well the evidence backs what was said, reported as its COMPONENTS.nullon a refusal, because there is no answer to support.support_scoreis the one number derived fromanswer_support: the share of sentences that cite a passage, times 0.9 on a partial read, from 0 to 1, andnullwhen there is no answer. It is uncalibrated, so show it as a measure of citation, not a probability, and do not set a pass/fail threshold on it.corpus_coverageis how much of what was available was actually read.completeis false whenever anything was withheld, including when retrieval hit its limit (retrieval_truncated) or a document was only partly read, anddocuments[]gives each document'sspans_seenagainst itsspans_total. A refusal that read nothing is nevercomplete.known_gapsis what the engine knows it did not use, typed:partial_document,budget_limit,retrieval_limit,hash_verification_failed,index_partial, andconflict_omitted(two passages disagree and the budget could not fit both, so neither side was served).verification_statusis one ofcited,uncited,no_evidence, orgeneration_failed(recorded when the model could not be reached). A refusal is not a status of its own: it isanswer: nullwith arefusal_reason, underno_evidenceoruncited. It describes the answer's citations only, and a verified answer keeps it: what verification found is on each claim, whose ownverification_statususes a different set of values (see Verified answers).conflictsis what the served passages disagree about:pairsof span ids, anddetectionnames the detector. Zero conflicts from a lexical detector is not a claim that the sources agree.query_planis what the planner decided and why:strategy,reason, andclaims_completeness, which says whether that strategy would see everything in scope. Reported on refusals too. The strategy decides what is read:full_readreads the whole matching document,positionalreads the section or list the question points at (from its end when the question asks for the latest entry),refusereads nothing, andrankedreads the most relevant passages.read_strategyis the read that actually ran. It equalsstrategyunless afull_readorpositionalplan could not be executed (its region did not fit the document as read), in which case it isrankedandplan_fallback_reasonsays why. The character budget still applies to every read, so checkcorpus_coverage.completefor what was actually read, notclaims_completeness.executionis which checks have run on this answer: itsstage, whether it isterminal, and theremaining_actions. A draft saysdrafted, not complete. On a verified answerremaining_actionsalso names what the engine could not do from inside this answer, such aswider_read.assuranceis the tier you asked for (requested), the one the served answer reflects (applied), and thereasonwhen they differ.
An answer verified above draft also carries verification, and control:
how much verification it got, and why that was enough or why it was not. See
Verified answers.
An answer can be perfectly SUPPORTED and badly COVERED: every quote genuine and
verifiable, while most of the corpus was never read. Treat a high
answer_support with corpus_coverage.complete: false as "correct about what
it saw", not "correct".
Status codes
Most errors carry error, a stable code to branch on, and message, a
sentence for people. Three other bodies are possible:
- A
422from input validation hasmessageplus anerrorsobject keyed by field. - A plan refusal has
message,error_code: "pro_feature"and theflagit needed. - A plain not-found or access refusal, such as an unknown span or knowledge id,
has only
message.
Branch on the status code first, then on error when there is one.
| Code | Meaning |
|---|---|
| 200 | Served. Empty results / evidence means no matches, not an error. Also an ingest that stored a new_version or reused one, and a delete |
| 304 | GET /answers/{snapshot_id} with an If-None-Match matching the latest stage |
| 201 | /ingest created a document, or a company library write added one |
| 202 | /ingest queued the document (job_id). Nothing is in the corpus yet. /investigations accepted one (investigation_id), with its cost estimate and where to poll. Also an MCP notification |
| 401 | Missing, invalid or expired token |
| 402 | /ask or /verify on a system key with too few credits for the whole request (error_code: insufficient_quota). No model was contacted |
| 403 | Token lacks the ability the endpoint needs (kee:read, kee:ingest, kee:admin, kee:investigate), or your plan lacks its feature: kee_api, knowledge_ingest, knowledge_ingest_upload for a file upload, or kee_verify for /verify, /investigations and assurance above draft. A plan refusal carries error_code: pro_feature and the flag it needed |
| 404 | Unknown span, knowledge item or job, or one you are not allowed to read (same response either way). unknown_document on a delete of an external_id never sent. type_not_available on /claims/query |
| 409 | /ask with consistency: strict_freshness while the corpus is mid-update (corpus_not_fresh). Nothing was answered. request_in_progress: an Idempotency-Key whose first request is still being answered. verification_not_released on /verify and /investigations before verification is available, and nothing was started. investigation_closed when adding a hypothesis to a cancelled or failed investigation, investigation_not_ready when recording a verdict before it has finished gathering, and too_many_active_investigations when two are already queued or running. On a reference-only /ingest, source_version_conflict: a different content_sha256 under a source_version_id GPTree already holds |
| 422 | Invalid input, a project_id you do not own, an unknown or unavailable consistency mode, or as_recorded_at without as_of_sequence. On /ingest, also empty_document, unsupported_file_type and contradictory_evidence_mode. idempotency_key_reused on /ask, and nothing_to_verify on /verify |
| 429 | Rate limited. See below |
| 502 | /ask could not reach the model (generation_failed). The assembled evidence is still returned |
| 503 | retrieval_unavailable: the index cannot serve right now. /investigations answers unavailable when investigations are switched off, and nothing was started. No partial or differently-ranked results are ever substituted. On /ask, also generation_unavailable: no model is available to your account. The store behind /knowledge, /ingest and /claims/query answers knowledge_unavailable or unavailable, and nothing was written |
Rate limits
Per account, and these are the numbers that actually bind:
| Endpoints | Limit |
|---|---|
/search, /context, /evidence, /knowledge/*, /answers, GET /investigations/{id}, /ingest, /jobs, /webhooks, MCP |
60 per minute |
/ask, /verify, and every other /investigations call |
12 per minute |
/ask is far tighter because every call costs real money. Polling an answer or
an investigation calls no model, so it counts against the 60, never the 12. There is a broader
per-account ceiling above these, but it is generous enough that the numbers
above are what you will hit first, so budget against them.
Responses carry X-RateLimit-Limit and X-RateLimit-Remaining. A 429 also
carries Retry-After, in seconds: wait at least that long, and back off
further on repeated 429s.
Known limitations in v1
- Search covers what you own. Passages shared with you through project
collaboration can be opened via
/evidence/{id}but do not appear in your search results yet. - Lexical matching only. A query has to share words with the passage.
- New chat attachments only. Files attached before this release are readable in chat but are not part of the evidence corpus.
- Source code attachments are not indexed as evidence.
/searchhas no pagination. Ask for a largerlimitor narrow the scope. (GET /knowledgeand its versions are paginated.)
Reference-only documents
Pushing a reference instead of text
If a document may not leave your systems, push a reference to POST /ingest
instead of its text. Send these in place of body, and GPTree stores only them, never the
content:
| Field | Type | Notes |
|---|---|---|
source_version_id |
string, required | up to 191 characters. Your system's immutable id for this version |
source_location |
string, required | up to 2048 characters. Where the content lives, precisely enough to fetch it. Never put credentials here |
content_sha256 |
string, required | 64 lowercase hex characters: the SHA-256 of the content, computed by you |
byte_size |
integer | optional |
evidence_mode |
string | optional: source_attested_reference. Omit it, or send canonical_snapshot, for a normal body |
body and file are refused alongside these fields, and so is
evidence_mode: canonical_snapshot. The response is 201 with
"evidence_mode": "source_attested_reference", "spans": 0 and
"retrievable": false. Re-sending a source_version_id GPTree already holds,
with the same content_sha256, returns 200 and refreshes attested_at; a new
source_version_id is a new version, even when the content hash is unchanged.
A different content_sha256 under a source_version_id GPTree already holds
returns 409 with "error": "source_version_conflict" and changes nothing:
changed content needs a new source_version_id.
A reference-only version can be cited and its hash checked against your
source, but it is not searchable: GPTree has no text to index, so it never
appears in /search, /context or /ask results.
Reading a reference-only version
GET /knowledge/versions/{version_id}/text carries four more fields:
{
"data": {
"...": "...",
"content_available": true,
"content_unavailable_reason": null,
"attestation": null,
"evidence_mode": null
}
}
evidence_mode is source_attested_reference when you pushed a reference
only. Anything else, canonical_snapshot or null, means GPTree stored the
text; a document pushed with a body carries null today.
content_available: false comes with a content_unavailable_reason:
source_attested_reference: a reference-only version. GPTree never held its text, sotextisnullandattestationcarriessource_version_id,source_location,content_sha256,attested_atandalgorithm.content_missing: GPTree should hold this version's text and does not. That is a fault on our side, not a property of your document, and it is reported rather than served as an empty string.
GET /evidence/{span_id} carries two more, unavailable_reason and attestation.
available: false comes with an unavailable_reason:
quote_unverified: the stored passage no longer matches its source (the document changed under it), or its text is missing. GPTree reports that rather than returning text it cannot stand behind.source_attested_reference: the passage belongs to a reference-only version (see Pushing a reference instead of text, above), whose text GPTree never held.attestationthen says where the content lives and which version was attested. Reference-only documents carry no passages today, so/evidencedoes not return this yet; it is defined so a client never has to guess.
attestation is null whenever the quote is available.
This page as raw markdown: /docs/knowledge-api.md. Everything at once: /docs/all.md. New to GPTree? Start with Learn.