AgentLex

API reference

JSON in, JSON out. The base URL is https://api.agentlex.si (a local server runs at http://localhost:4400). Authenticate with Authorization: Bearer al_.... Errors come back as {"error": {"code", "message"}} with a meaningful status. Every authenticated response carries rate-limit headers.

The same surface as a machine-readable document: /openapi.json (OpenAPI 3.1). Try calls without writing code in the console. Single-file clients for JavaScript and Python are described under SDKs.

Getting started

Four calls: a key, a collection, a document, a search.

curl -X POST https://api.agentlex.si/v1/signup \
  -H 'content-type: application/json' -d '{"email": "you@institute.org"}'
# {"account": {...}, "api_key": "al_...", "note": "Store this key now; it is not shown again."}

export KEY=al_...
curl -X POST https://api.agentlex.si/v1/collections \
  -H "authorization: Bearer $KEY" -H 'content-type: application/json' \
  -d '{"name": "Case files", "settings": {"domain": "legal"}}'
# {"id": "col_8sK2mQ1xYz", ...}

curl -X POST https://api.agentlex.si/v1/collections/col_8sK2mQ1xYz/documents \
  -H "authorization: Bearer $KEY" -H 'content-type: application/json' \
  -d '{"title": "MSA", "text": "MASTER SERVICES AGREEMENT\n\n1. Definitions\n..."}'
# {"id": "doc_Qx1pL9vB2n", "pages": 3, "chunks": 11, "chunker": "legal", ...}

curl -X POST https://api.agentlex.si/v1/collections/col_8sK2mQ1xYz/search \
  -H "authorization: Bearer $KEY" -H 'content-type: application/json' \
  -d '{"query": "audit rights", "k": 5}'

The same in JavaScript and Python with the clients:

import { AgentLex } from './agentlex.js';
const { api_key } = await AgentLex.signup('you@institute.org');
const agentlex = new AgentLex({ apiKey: api_key });
const col = await agentlex.collections.create('Case files', { domain: 'legal' });
await agentlex.documents.add(col.id, { title: 'MSA', text });
const found = await agentlex.search(col.id, { query: 'audit rights', k: 5 });
from agentlex import AgentLex
agentlex = AgentLex(api_key=AgentLex.signup("you@institute.org")["api_key"])
col = agentlex.collections.create("Case files", {"domain": "legal"})
agentlex.documents.add(col["id"], {"title": "MSA", "text": text})
found = agentlex.search(col["id"], {"query": "audit rights", "k": 5})

Three public endpoints need no key: GET /v1/health (version, embedder, rerankers, whether a model is configured, and a features list a client can check before it sends), GET /v1/plans, and POST /v1/chunk, the chunk preview. Request bodies are limited to 25 MB.

Authentication and keys

POST /v1/signup no key

{"email"} returns 201 {"account": {"id", "email", "plan"}, "api_key", "note"}. The key is shown once; only its hash is stored. An email that already has an account gets that account and a fresh key.

GET /v1/account

{"id", "email", "plan", "usage", "allowances", "keys"}. usage holds this month's counts (search, extract, research, pages_indexed; an operation not yet used is absent) and stored_chars, the characters of page text on the account. allowances is the plan object from /v1/plans, its rate included. keys lists {"prefix", "created_at", "revoked_at"} per key.

POST /v1/keys

A second key for the same account: 201 {"api_key", "prefix", "note"}. The prefix is the key's first 10 characters, al_ included.

GET /v1/keys

{"keys": [{"prefix", "created_at", "revoked_at"}]}, oldest first.

DELETE /v1/keys/:prefix

Revokes a key: {"revoked": prefix, "revoked_at"}. From then on the key is refused with 401 invalid_key. The key making the request cannot revoke itself (400 cannot_revoke_current); an unknown prefix is 404 key_not_found.

Collections

A collection is one searchable library. It records the embedding model it was built with; a server that embeds with a different model refuses to add vectors to it (409 embedder_mismatch) and can still search it in keyword mode.

POST /v1/collections

{"name", "settings"?} returns 201 with the collection row. Settings are validated strictly: an unknown key or a wrong type is 400 invalid_settings, so a typo never leaves a record unmasked.

SettingDefaultEffect
domainautomedical, legal, classical or general picks the chunker every document gets (general means prose) and the default domain for search and research. auto detects per document.
mask_identifiersfalseIdentifiers (names, SSNs, phones, MRNs, ...) are replaced by placeholders in what the embedding provider and the extraction reader see. The stored text keeps them; search results and research read the stored text.
redact_storagefalseText, title and author are masked before chunking and only the masked form is stored; the originals are never written. The document's meta then carries identifiers (counts by kind) and redacted: true. One value keeps one placeholder across the pages of a document.

The collection row

{
  "id": "col_8sK2mQ1xYz", "account_id": "acct_...", "name": "Case files",
  "embedder": "jina-embeddings-v3", "created_at": "2026-10-15T10:00:00.000Z",
  "settings": {"mask_identifiers": false, "redact_storage": false, "domain": "legal"},
  "documents": 12, "pages": 340, "chunks": 1180,
  "domain": "legal"        // the chunker most documents used; null while empty
}

GET /v1/collections

{"collections": [row]}, oldest first.

GET /v1/collections/:id

One row, or 404 collection_not_found.

PATCH /v1/collections/:id

{"name"?, "settings"?}. Settings are merged over the current ones, so {"settings": {"redact_storage": true}} changes that one. Changing settings does not touch documents already indexed.

Two settings carry an organization's own ways: guidance, up to 2,000 characters of house rules added to the research and answer prompts (they add to the built-in rules and never override them), and glossary, up to 300 terms of the organization's shorthand, each spelled out in queries before the built-in lexicons, like {"SSA": "Software Services Agreement", "PO": ["purchase order", "product owner"]}. null removes either.

DELETE /v1/collections/:id

Deletes the collection with every document in it: {"deleted": id}.

Documents

POST /v1/collections/:id/documents

Send what you have. The body is a JSON object with exactly one payload field, or a bare JSON string (a text document). The server converts the payload to pages, cuts the pages into chunks, embeds the pages and stores everything in one transaction.

FieldMeaning
title, authorShown with every result, passed to the reranker and the research model. An HTML <title> and <meta name="author"> fill them in when missing.
metadataAny object, stored with the document and matched by filters.metadata. A date, encounter_date, effective_date or filed field feeds filters.date.
textPlain text or Markdown. Form feeds or lines reading --- page 12 --- split it into pages (the token after page is the page number); otherwise the chunker paginates it.
pages[]{"text", "number"?, "volume"?, "heading"?} per printed page, or bare strings. Page numbers and volumes are kept for citation. Empty pages are dropped.
htmlTags become Markdown structure (headings, lists, pipe tables, quotes, code fences); script, style, nav, header and footer are dropped.
csv, tsvRows become pipe tables of 40 rows per page with the header repeated on every page; the header cells go to metadata.columns.
jsonA JSON string, an array of records (one page per record, nested keys joined with dots) or an object with text or pages[].
contentThe payload when format says what it is.
formatauto (default), text, markdown, html, csv, tsv, json or pages. Detection reads pages[], then the named payload field, then the text itself (five or more tags is HTML, a leading bracket is JSON, consistent delimiters are CSV or TSV). A classical Arabic text full of print markup is safest sent as pages[] or with format: "text".
chunkerclassical, medical, legal or prose. When absent: what the format implies, else the collection's settings.domain, else detected from the text.

Response

201 {
  "id": "doc_Qx1pL9vB2n", "pages": 3, "chunks": 11, "chars": 6210,
  "chunker": "legal", "embedder": "jina-embeddings-v3", "format": "text",
  "meta": {"kind": "legal", "form": "contract", "terms": ["Confidential Information"], "sections": ["1", "1.1", "2"], "citations": []},
  "identifiers": {"total": 0, "counts": {}}     // what the original text carried, masked or not
}

meta is what the chunker found: {"kind"} plus, for medical records, encounterDate, dates, codes and sections; for legal texts, form, citations, terms and sections. Under redact_storage it also carries identifiers and redacted: true. chars counts against the plan's stored text.

Errors: 400 invalid_document (unusable input, or nothing to index), 413 document_too_large (over 20,000,000 characters), 409 embedder_mismatch, 429 storage_exceeded.

GET /v1/collections/:id/documents

{"documents": [{"id", "title", "author", "metadata", "chunker", "pages", "chunks", "chars", "created_at", "meta"}]}, oldest first.

GET /v1/collections/:id/documents/:doc

One such row, or 404 document_not_found.

DELETE /v1/collections/:id/documents/:doc

{"deleted": id}.

Formats and chunkers

A document becomes pages (what a result points at) and each page becomes chunks (what a search reads). The chunker decides where to cut and what each chunk is.

ChunkerForWhat it does
proseBooks, reports, web pages, notesCuts at headings and paragraphs into pages of about 2,400 characters and chunks of about 700. Tables stay whole and are marked table; lists list.
classicalClassical Arabic printBody and footnotes apart (a footnote chunk names its body chunk in footnoteOf), a hadith with its chain whole, a verse with its commentary, poetry together; marks hadith, grading, verse, poetry, footnote, takhrij.
medicalClinical notes and recordsOne encounter per page where the note allows; sections become chunks marked medication, lab, vital, diagnosis, plan, allergy; dates and codes go to the page meta so date filters work.
legalContracts, statutes, pleadingsSections and numbered clauses become chunks marked clause, definition, citation; the clause label and section path go to the chunk meta.

Detection (chunker absent and settings.domain: "auto") samples the text for print cues of classical Arabic, clinical section headings or legal section numbering, and falls back to prose.

Kinds

Every chunk carries a set of kinds, the names of the flags its chunker set. They appear on passages, on chunks of the page endpoint and on research sources, and kinds in a request keeps only passages of those kinds.

WhereKinds
anywhereheading, table, list, date, speaker
classicalhadith, grading, verse, poetry, footnote, takhrij
medicalmedication, lab, vital, diagnosis, plan, allergy
legalcitation, definition, clause

Two bookkeeping flags, agentlex and code, may also appear; a search does not filter by them. An unknown kind in a request is ignored and named in warnings.

Pages and the chunk preview

GET /v1/collections/:id/documents/:doc/pages/:n

The page's full text with its chunks and their offsets into it. n is the page index counted from 0, the page.n every search result and research source carries; number is the printed page number when there was one.

{
  "document": {"id": "doc_Qx1pL9vB2n", "title": "MSA", "author": null},
  "page": {"n": 1, "number": null, "volume": null, "heading": "2. Audit", "text": "...", "meta": {"label": "2", "path": ["2"]}},
  "chunks": [{
    "pos": 0, "text": "2.1 Provider shall permit one audit per year on reasonable notice.",
    "kinds": ["clause"], "heading": "2. Audit",
    "start": 10, "end": 77,       // page.text.slice(start, end) is the chunk
    "meta": {"label": "2.1"}, "footnoteOf": null
  }]
}

start and end are null when the chunker could not place a chunk. Errors: 400 invalid_page (not a whole number), 404 document_not_found, 404 page_not_found.

POST /v1/chunk no key

The same input as a document. Nothing is stored. Returns {"chunker", "format", "meta", "pages": [{"n", "number", "volume", "heading", "chars", "meta", "chunks": [chunk]}]} with the chunks shaped as above, so a converter can be checked before anything is indexed.

Identifiers and masking

Records can carry names, numbers and addresses that should not reach a third-party model, or should not be stored at all. The scanner finds them by kind with offsets; the masker replaces each with a placeholder and the same value of a kind keeps one placeholder within a text.

KindPlaceholderFound by
ssn[SSN-n]123-45-6789, or nine digits after an SSN label
phone[PHONE-n]+country, (area) and 3-3-4 forms with extensions, or a run of digits after a phone label
email[EMAIL-n]an address
mrn[MRN-n]a value after MRN, Patient ID, Account No., Chart No., Policy No., Claim No. and the like
dob[DOB-n]a date after DOB or Date of Birth
credit_card[CARD-n]a Luhn-valid number in 4 to 6 digit groups
ip, url[IP-n], [URL-n]an address or a link (url is counted but not masked by default)
passport, npi[PASSPORT-n], [NPI-n]a value after its label
address[ADDRESS-n]a street address
person[NAME-n]a person's name (Unicode letters; José, Müller)
date[DATE-n]a date (counted but not masked by default: usually content, not identity)

Precision over recall where a wrong hit would mangle the document: a bare run of digits is an identifier only next to a label, so lab values, ICD codes, dosages, section numbers and money are left alone. Every upload is scanned and the counts are returned as identifiers whether or not anything is masked. Request bodies and query text are never logged.

POST /v1/scan

{"text", "kinds"?} returns {"total", "counts": {kind: n}, "kinds": [kind], "masked_preview"}: the counts and the first 300 characters of the masked text. Spans are never returned, so the identifiers are not echoed back. With kinds only those kinds are counted and masked. An empty text is 400 invalid_text.

POST /v1/scan {"text": "Call 555-123-4567 or write to ada@example.com about the 2024-01-15 visit."}
{"total": 3, "counts": {"phone": 1, "email": 1, "date": 1}, "kinds": ["phone", "email", "date"],
 "masked_preview": "Call [PHONE-1] or write to [EMAIL-1] about the 2024-01-15 visit."}

What the two collection settings do with this is under Collections. In short: mask_identifiers protects the embedding provider and the extraction reader; redact_storage keeps the identifiers out of the database altogether, and the extract response says masked: true under either.

POST /v1/collections/:id/search

The query is understood first (quoted phrases that must match, the intent, abbreviations spelled out, section forms, Arabic spelling variants). One keyword list per query form (BM25 over chunks, rolled up to pages) runs beside a semantic list (page vectors) and a fields list (title and headings). Filters restrict every list before fusion. Weighted reciprocal rank fusion, then must-match phrases, near-duplicate folding, a cross-encoder rerank when one is configured, and the best passages of each page.

FieldMeaning
queryRequired, at most 4,000 characters. A quoted phrase must appear on the page; when no page has it, the unfiltered order is returned and warnings says so.
kPages to return, 1 to 50 (default 10).
modehybrid (default), keyword (exact terms, names, and the only mode that works across an embedder change) or semantic.
passagesBest passages per page, 1 to 10 (default 2).
rerankfalse keeps the fused order even when a reranker is configured.
expandtrue (default) runs the deterministic rewrites; "llm" adds model paraphrases (needs a model; falls back silently); false searches the query as typed.
domainmedical, legal or general: which abbreviation lexicon applies. Defaults from the collection's settings.domain, else from its documents.
kinds, documentsShorthand for filters.kinds and filters.documents; both spellings merge.
filtersSee Filters.

Response

{
  "query": "audit rights", "mode": "hybrid", "domain": "legal", "expand": true,
  "intent": "keyword",                 // phrase | list | question | entity | keyword
  "phrases": [],                       // quoted phrases that had to match
  "expansions": [{"text": "audit rights section", "kind": "section", "weight": 0.6}],
  "filters": {"documents": [], "kinds": [], "metadata": null, "date": null, "headings": null},
  "warnings": [],
  "results": [{
    "rank": 1, "citation": "AL-4F2A91C0", "score": 0.0325,
    "document": {"id": "doc_Qx1pL9vB2n", "title": "MSA", "author": null},
    "page": {"n": 1, "number": null, "volume": null, "heading": "2. Audit"},
    "heading": "2. Audit",
    "passages": [{
      "id": 381, "pos": 0,
      "text": "2.1 Provider shall permit one audit per year on reasonable notice.",
      "kinds": ["clause"], "heading": "2. Audit",
      "offsets": {"start": 10, "end": 77},   // into the page text; null when unknown
      "matched": ["audit"],                  // query terms the passage holds
      "meta": {"label": "2.1"}
    }],
    "retrievers": ["keyword", "semantic"], "duplicates": 0, "alsoIn": 0
  }],
  "reranker": {"name": "jina", "model": "jina-reranker-v3"},
  "timings": {"expandMs": 1, "keywordMs": 3, "embedMs": 412, "semanticMs": 419, "fieldsMs": 1, "rerankMs": 380, "totalMs": 806},
  "considered": 14
}
Result fieldMeaning
citationAL- and eight hex digits: a stable id for this page of this document, the same in every search, in extraction, in research sources and in evaluation.
scoreThe fused score, comparable within one response only.
page.nThe page index for GET .../pages/:n; page.number is the printed number when the document had one.
retrieversWhich lists ranked the page: keyword, semantic, fields.
duplicates, alsoInNear-duplicate pages folded into this one, and other documents carrying the same text.
expansions[].kindabbreviation, section, variant or llm.

Errors: 400 invalid_query, 400 query_too_long, 400 invalid_filter, 409 embedder_mismatch, 429 allowance_exceeded. One search counts against the monthly allowance.

Filters

Search, extraction, research and evaluation take the same filters object. Filters restrict the candidate pages before fusion rather than trimming the fused order, so a narrow filter still fills k when the pages exist. The response echoes what was applied, with the shorthand fields merged in.

FilterMeaning
documentsDocument ids.
kindsOnly passages of these kinds. A page that matches otherwise but has none still returns its best chunks.
metadataEach key must equal the document's metadata value, as a string compare. A list on either side matches when any pair agrees; a dotted key reaches into nested objects: {"case.court": "9th Cir."}.
date{"from"?, "to"?}, either bound optional. Accepts 2024-03-14, 03/14/2024, 14 Mar 2024, March 14, 2024 or a bare year. A page is dated by its document's date, encounter_date, effective_date or filed metadata and by the dates the chunker found on it. An unreadable date is 400 invalid_filter.
headingsOnly pages whose heading or section headings contain this text.

In research the request's filters bound every search the model issues; the model's own filters only narrow within them.

Extraction

POST /v1/collections/:id/extract

Searches, then a fast reader goes through each page found and copies out, exactly as printed, only the sentences that answer the question. Every excerpt is matched against its page by code; one that is not on the page is counted in rejectedExcerpts and never returned. A writer downstream reads a few thousand tokens of proven excerpts instead of whole pages. Needs a model on the server.

FieldMeaning
questionRequired.
kPages to search and read, at most 20 (default 8). Each page read counts one extract against the allowance.
mode, kinds, documents, filters, domainAs in search.
alltrue also returns the pages the reader judged not relevant, with their notes and no excerpts.
{
  "question": "How often may the customer audit the provider?",
  "pages": [{
    "citation": "AL-4F2A91C0", "relevant": true, "note": "States the audit frequency.",
    "excerpts": [{"text": "2.1 Provider shall permit one audit per year on reasonable notice.", "match": "exact", "offset": {"start": 10, "end": 77}}],
    "rejected": 0,
    "document": {"id": "doc_Qx1pL9vB2n", "title": "MSA", "author": null},
    "page": {"n": 1, "number": null, "volume": null, "heading": "2. Audit"}
  }],
  "kept": 1, "rejectedExcerpts": 0,
  "usage": {"input_tokens": 1830, "output_tokens": 96}, "model": "claude-haiku-5-5",
  "masked": false          // true when the reader saw masked text
}

match is exact or normalized_exact (equal once vowel marks and letter variants are set aside); offset places the excerpt in the page text when it is there as written. Under mask_identifiers the reader sees masked pages and its excerpts are verified against them, so an excerpt can carry a placeholder but never an identifier.

Research

POST /v1/collections/:id/research

A model researches the collection in a tool loop (search and read, until it calls done or the step cap ends it), an answer is written from the pages it found with [n] citations, every quotation is checked against the page it cites, and every sentence is judged against its cited sources. The question itself is searched before the model speaks, so sources are on screen at once. At most two research calls run at a time per account.

FieldMeaning
questionRequired.
tierfast (default): a small model researches for up to 3 steps, about 10 seconds. deep: a stronger model, up to 8 steps.
historyEarlier turns, [{"role": "user" | "assistant", "content"}]; the last six are used.
streamtrue streams NDJSON events (next section) instead of one JSON result.
documents, kinds, filtersBound every search the model issues; kinds folds into filters.kinds.
domainmedical, legal, classical or general: how the documents are organized and where their answers hide, and which search lexicon applies. Defaults from the collection's settings.domain, else from its documents.

Result

{
  "answer": "The provider must permit one audit per year on reasonable notice [1] ...",
  "sources": [{
    "n": 1, "citation": "AL-4F2A91C0",
    "document": {"id": "doc_Qx1pL9vB2n", "title": "MSA", "author": null},
    "page": {"n": 1, "number": null, "volume": null, "heading": "2. Audit"},
    "heading": "2. Audit", "kinds": ["clause"], "meta": {"label": "2.1"},
    "cited": true,                      // whether the answer cites [n]
    "passages": ["2.1 Provider shall permit one audit per year on reasonable notice.", "..."]
  }],
  "verification": {
    "quotes": [{"quote": "permit one audit per year", "source": 1, "status": "exact"}],
    "verified": 1, "total": 1,
    "claims": [{"text": "The provider must permit one audit per year on reasonable notice", "sources": [1],
                "status": "supported", "evidence": "Provider shall permit one audit per year on reasonable notice.", "start": 0, "end": 70}],
    "counts": {"supported": 2, "partial": 0, "unsupported": 0, "uncited": 0, "unchecked": 0, "total": 2},
    "summary": "2 of 2 claims supported by their sources."
  },
  "queries": [{"query": "audit frequency", "mode": "hybrid", "results": 3, "filters": {...}}],
  "note": "Found the audit clause.",   // the agent's one line: what was found, what is missing
  "tier": "deep", "domain": "legal", "filters": {...},
  "usage": {"input_tokens": 9120, "output_tokens": 410},
  "timings": {"researchMs": 6100, "answerMs": 2900, "verifyMs": 1800, "totalMs": 10800, "steps": 2}
}
Verification fieldMeaning
quotes[].statusEach quotation in the answer against the page it cites (the first [n] after it): exact, normalized_exact, paraphrase, translation, unverified, or uncited when no source follows it. verified counts the first two.
claims[].statusEach sentence against its cited sources only: supported (a cited source states it), partial (only part is stated, or the wording goes beyond the source), unsupported (not stated, or contradicted), uncited (no [n]), unchecked (the judge did not run). A claim citing a source that does not exist is unsupported.
claims[].evidenceA span the judge pointed to, kept only when it really is in a cited source; otherwise null.
claims[].start, endOffsets of the sentence in answer, for highlighting.

Errors: 400 invalid_question, invalid_tier, invalid_domain, invalid_filter; 429 allowance_exceeded, too_many_concurrent; 503 llm_not_configured; 502 llm_error, llm_unreachable. A research call spends five rate tokens and one research of the monthly allowance.

The event stream

With "stream": true the response is application/x-ndjson: one JSON object per line, written as the work happens. The HTTP status is then always 200; a failure after the stream opens arrives as a final error line. Errors the server can see before it starts (a bad key, too_many_concurrent, no model) are ordinary JSON errors with their status.

EventFieldsWhen
statusstage: searching, deepening, answering, verifying; step from 1 during researchAt each phase and each research step.
queryquery, mode, resultsEach search the agent ran, as it completes.
sourcessources: every source found so far, {n, citation, document, page, heading, kinds, meta}After the first search and after each step.
deltatext: the next piece of the answerAs the answer streams.
answertext: the whole answerWhen the answer is complete.
claimscounts, summaryWhen the claims have been judged.
doneresult: the full result shown aboveLast, on success.
errorcode, messageLast, on failure.
{"type":"status","stage":"searching"}
{"type":"query","query":"How often may the customer audit?","mode":"hybrid","results":3}
{"type":"sources","sources":[{"n":1,"citation":"AL-4F2A91C0", ...}]}
{"type":"status","stage":"searching","step":1}
{"type":"query","query":"audit records retention","mode":"keyword","results":2}
{"type":"sources","sources":[...]}
{"type":"status","stage":"answering"}
{"type":"delta","text":"The provider "}
{"type":"delta","text":"must permit one audit per year [1]."}
{"type":"answer","text":"The provider must permit one audit per year [1]."}
{"type":"status","stage":"verifying"}
{"type":"claims","counts":{"supported":1,"partial":0,"unsupported":0,"uncited":0,"unchecked":0,"total":1},"summary":"1 of 1 claims supported by its source."}
{"type":"done","result":{...}}

Closing the connection cancels the research. The console at /console reads this stream.

Evaluation

POST /v1/collections/:id/evaluate

How a change to chunking, fusion weights or a reranker is judged: a set of questions with known relevant pages, run through search once per variant and scored. Relevant items are resolved to pages before any search runs, so a gold-set error (a snippet on no page, a missing document) becomes a note and the retriever is never blamed for a question it could not have answered.

FieldMeaning
questionsRequired, 1 to 1,000 of {"query", "relevant": [item], "gains"?}. Any other key a question carries (kinds, documents, filters, passages) is passed to search.
relevant[]{"citation"} a citation id; {"document", "page"} a document id or title and the page index; {"text", "document"?} a snippet the page text contains, as written. Two items that name one page merge.
gainsOne number per relevant item (default 1) for graded nDCG.
kThe cutoff, 1 to 50 (default 10).
variantsAny of keyword, semantic, hybrid, hybrid+rerank; default all four. Without a reranker, hybrid+rerank equals hybrid and a note says so.
expandPassed to every search.
{
  "n": 2, "k": 5, "embedder": "jina-embeddings-v3", "reranker": null,
  "variants": {
    "keyword": {"ndcg": 0.8155, "precision": 0.2, "recall": 1, "mrr": 0.75, "map": 0.75, "latency": {"p50": 4.1, "p95": 6.3}, "found": 2},
    "hybrid":  {"ndcg": 1, "precision": 0.2, "recall": 1, "mrr": 1, "map": 1, "latency": {"p50": 31.2, "p95": 40.8}, "found": 2}
  },
  "perQuestion": [{"query": "audit per year", "variant": "keyword", "ndcg": 1, "firstHit": 1, "latencyMs": 4.1,
                   "missing": [], "ranked": ["AL-4F2A91C0", "AL-0B77D1E3"], "hits": [{"item": "permit one audit per year", "rank": 1, "citation": "AL-4F2A91C0"}]}],
  "notes": []
}

n is the number of questions with at least one resolvable relevant item; found the questions with a relevant page in the top k. One search per question per variant counts against the allowance, and the call spends five rate tokens. An empty or oversized list is 400 invalid_questions.

Sample corpora

POST /v1/collections/:id/sample

{"domain": "medical" | "legal" | "archive"} indexes the server's bundled sample documents of that domain into the collection, one by one through the same path as adding a document (metadata kept, storage checked first, pages metered): 201 {"documents", "pages", "chunks"}. Errors: 400 invalid_domain, 404 sample_not_found (this server has no such sample), 429 storage_exceeded.

Rate limits and allowances

Two limits apply to a key. The rate is a token bucket per account: it holds burst tokens and refills at perMinute per minute, so a client indexing a book can send a burst and then settle at the plan's rate. Research and evaluate spend five tokens, every other authenticated call one. The allowances are monthly counts per operation and a cap on stored text.

HeaderMeaning
x-ratelimit-limitThe burst: the most tokens a key can spend at once.
x-ratelimit-remainingTokens left after this request.
x-ratelimit-resetSeconds until the bucket can serve again; 0 while tokens remain.
retry-afterOn a 429 rate_limited: seconds to wait, at least 1.

The headers are on every authenticated response, success or failure (a 404 on an authenticated route spent a token too). Unauthenticated responses carry none. The headers are exposed to browsers through CORS.

PlanRateStored textSearchesPages extractedResearch
scribe60 per minute, burst 2020,000,000 chars2,00050050
scholar600 per minute, burst 1001,000,000,000 chars100,00020,0003,000
library6,000 per minute, burst 500Unlimited (served as null in JSON)

Allowances reset monthly. Past one, the call returns 429 allowance_exceeded; past the stored text cap, 429 storage_exceeded. GET /v1/plans lists them and GET /v1/account shows this month's use against them.

Errors

Every error is {"error": {"code", "message"}}. The code is stable and meant for programs; the message is for people.

StatusCodes
400invalid_json, invalid_email, invalid_name, invalid_settings, invalid_document, invalid_query, query_too_long, invalid_filter, invalid_question, invalid_questions, invalid_tier, invalid_domain, invalid_text, invalid_page, cannot_revoke_current
401missing_key, invalid_key (unknown or revoked). An unauthenticated request to any other /v1 path is a 401 before it is a 404.
404not_found, collection_not_found, document_not_found, page_not_found, key_not_found, sample_not_found
409embedder_mismatch
413body_too_large (25 MB), document_too_large (20,000,000 characters)
429rate_limited (with retry-after), allowance_exceeded, storage_exceeded, too_many_concurrent
502llm_error, llm_unreachable, provider_unauthorized, provider_rate_limited, provider_timeout, provider_unavailable, provider_bad_response (an embedding or reranking provider failed)
503llm_not_configured (the server has no model; extract and research are unavailable), llm_busy (retry shortly)
500internal, sample_unreadable

SDKs

Two single-file clients with no dependencies, in sdk/ of the repository: agentlex.js (Node 20+ and browsers) and agentlex.py (Python 3.9+, standard library). Both expose the same surface: signup, health, plans, chunk, account, scan, collections.list/create/get/update/delete, documents.add/list/get/delete/page, search, extract, research, evaluate, sample, keys.create/list/revoke. Every call returns the server's JSON as is; the rate-limit headers ride along as a non-enumerable rateLimit property (JavaScript) or a rate_limit attribute (Python). Any non-2xx response raises AgentLexError with status, code, message, the rate limit, and retryAfter on a 429.

JavaScript

import { AgentLex, AgentLexError } from './agentlex.js';

const agentlex = new AgentLex({ apiKey: process.env.AGENTLEX_KEY });
const col = await agentlex.collections.create('Case files', { domain: 'legal', mask_identifiers: true });
await agentlex.documents.add(col.id, { title: 'MSA', text });

const found = await agentlex.search(col.id, { query: 'audit rights', kinds: ['clause'] });
console.log(found.results[0].citation, found.rateLimit.remaining);

try {
  const r = await agentlex.research(col.id, { question: 'How often may the customer audit?' });
  console.log(r.answer, r.verification.summary);
} catch (e) {
  if (e instanceof AgentLexError && e.code === 'rate_limited') console.log(`retry in ${e.retryAfter} s`);
  else throw e;
}

// Streaming: the promise resolves to an async iterable once the headers are in.
const events = await agentlex.research(col.id, { question, tier: 'deep', stream: true });
for await (const e of events) {
  if (e.type === 'delta') process.stdout.write(e.text);
  if (e.type === 'done') console.log('\n', e.result.verification.summary);
  if (e.type === 'error') console.error(e.code, e.message);
}

Python

from agentlex import AgentLex, AgentLexError

agentlex = AgentLex(api_key=os.environ["AGENTLEX_KEY"])
col = agentlex.collections.create("Case files", {"domain": "legal"})
agentlex.documents.add(col["id"], {"title": "MSA", "text": text})

found = agentlex.search(col["id"], {"query": "audit rights", "k": 5})
print(found["results"][0]["citation"], found.rate_limit)

for event in agentlex.research(col["id"], {"question": "How often may the customer audit?", "stream": True}):
    if event["type"] == "delta":
        print(event["text"], end="", flush=True)
    elif event["type"] == "done":
        print("\n", event["result"]["verification"]["summary"])

Without a client, the stream is plain NDJSON: read the body as it arrives, split on newlines, and JSON.parse each line. The console does exactly that in forty lines.