Skip to main content

Query, Streaming Query, and Search APIs

Use these endpoints to build cited chat and raw retrieval clients:

EndpointPurposeResponse type
POST /queryRun the full answer pipeline and return one cited answer as JSON.application/json
POST /query/streamRun the same answer pipeline, then reveal the already-guarded result as Server-Sent Events.text/event-stream
POST /searchReturn ranked source chunks only, with no answer synthesis.application/json

All routes are mounted at the API root. The public production edge proxies the complete FastAPI HTTP surface, including /healthz and /readyz. Documentation routes /docs, /redoc, and /openapi.json are available when api_docs_enabled=true, currently enabled in production. This page documents the three query and retrieval endpoints; see the other API reference pages for conversations, memory, ingestion, and document operations. There is no WebSocket query route.

Authentication and browser integration​

Every route on this page requires the configured API-key header. The default header name is X-API-Key.

X-API-Key: <server-side API key>

Principal-bearing deployments may also receive trusted identity headers from an upstream gateway or backend:

HeaderTypeMeaning
X-User-SubjectstringStable authenticated user subject.
X-User-RolestringAuthenticated role. Defaults to user only when the trusted resolver accepts fallback behavior.
X-User-UnitstringOptional organization unit for access filtering.
X-User-Levelinteger stringOptional position level for access filtering.

Browser applications must not embed a shared API key and must not let users forge X-User-* identity headers. Put these calls behind a same-origin backend-for-frontend or an API gateway that injects the key and identity headers server-side. The API does not add app-level CORS behavior for browser cross-origin calls.

For the production edge, server-side clients send X-API-Key and a supplied X-User-Subject when identifying the caller. Caddy preserves that subject but strips caller-supplied X-User-Role, X-User-Unit, and X-User-Level. On subject-less requests it strips all identity headers and injects no identity; FastAPI applies its configured authentication policy. The key-only examples below do not identify an individual caller. Set the URL and read the credential without storing it in shell history before running the curl examples on this page:

export API_BASE='https://rag-aceh-ai-eng-neo.reviewstagepro.com'
read -rsp 'Production API key: ' API_KEY && export API_KEY && printf '\n'

Native EventSource cannot send X-API-Key or trusted identity headers, so streaming clients must use fetch() plus ReadableStream parsing, as shown below. Clear the credential when testing is complete with unset API_KEY API_BASE.

Shared concepts​

Collection scoping​

/query, /query/stream, and /search accept collection_ids to narrow retrieval to partner collections.

ValueBehavior
Field omittedSearch all collections the caller may access.
nullSame as omitted.
[]Same as omitted.
String arrayTrim each entry, drop blank entries, deduplicate the remaining IDs in first-seen order, then restrict retrieval to the intersection of those IDs and the caller's access predicate.
Array containing only blank stringsSame as omitted: after normalization there is no collection scope, so all accessible collections are searched.
Nonblank unknown or forbidden IDProduces no matching rows for that normalized ID. It is not a 403 and does not reveal whether the collection exists.

Collection IDs are opaque stable strings. Examples include partner collection names such as JDIH, PPID, SatuData, and OpenData, but clients should treat them as configured IDs, not as an exhaustive enum.

Document scoping​

/query, /query/stream, and /search accept document_ids to narrow retrieval to selected AI-engine documents. Each value is the canonical UUID in documents.id, not the partner ingestion registry's documentId.

Partners obtain this UUID from aiDocumentId in a post-persistence Kafka status event, stored_document_id in upload/ingestion status, document_id in /search results, or document_id in answer citations. Retain that value when a user selects a named regulation or other document for focused questioning.

ValueBehavior
Field omittedSearch all documents the caller may access.
nullSame as omitted.
[]Same as omitted.
UUID arrayCanonicalize each UUID, deduplicate the remaining IDs in first-seen order, then restrict retrieval to their intersection with collection_ids when present and the caller's access predicate.
Malformed UUIDRequest validation fails with 422; malformed IDs are client input errors rather than a no-match focus.
Well-formed unknown or forbidden UUIDProduces no matching rows. It is not a 403 and does not reveal whether the document exists or is accessible.

A non-empty document focus is an answer-source guarantee: the Agentic RAG answer uses only retrieved chunks from the selected, visible indexed documents. It does not select SQL Query, OpenData, BPS, or Excel Query tools, because those can create live-data or raw-file synthetic grounding outside the selected document chunks.

Saved sources, an empty set, and MCP​

A workspace or notebook keeps its own saved source set, and that record governs the answer. Two empty cases are worth separating:

Saved source setmcp_source_idsBehavior
Explicitly empty (whole_corpus: false, no collection or document)A source (in the request, or the conversation's saved selection)200, answered from MCP alone: no document, sheet, or portal tool is offered, and no anchor retrieval runs.
Explicitly empty[], or omitted/null with no saved conversation selection400 — "This workspace has no sources…" / "This notebook has no sources…".
Sources that no longer resolve (deleted or unreadable)A source200, answered from MCP alone, with a fixed note that the saved documents are gone.
Holds documentsAnyToday's behavior: the saved document scope wins, and a selected public MCP source may add evidence as described for MCP queries.

The empty-set MCP-only rule needs no new request field. A follow-up may omit mcp_source_ids; the conversation's saved MCP selection applies. If MCP resolution leaves no usable tool, the request returns 400 instead of answering from the corpus the empty set excluded. Governed MCP sources stay withheld under a source boundary.

On /query, /query/stream, /query/v2, and /query/v2/stream, the workspace Owner and any Partner may save the chat's MCP selection, setting or clearing it, exactly as they may replace the workspace's saved document set. The saved selection changes the sources for every member of the chat. A Viewer is refused with 403: a Viewer cannot ask in the chat at all. A personal chat's selection is saved by its owner.

Workspace upload inventory​

For a chat belonging to a workspace, corpus_inventory distinguishes that workspace's accessible indexed uploads from the sources selected for the answer. It reports the exact uploaded, selected, and unselected counts, with bounded title lists. For example, eleven indexed uploads with one selected are reported as eleven uploaded, one selected, and ten unselected; unselected files cannot supply document content until added under Sumber.

Whole-corpus selection still counts only this workspace's own uploads in that workspace block; the separate source inventory counts all indexed documents in the accessible answer scope. Files still ingesting or failed are excluded. A query outside a workspace has no workspace upload block. Access uses the same membership predicate as GET /workspaces/{id}/documents.

Counts and the title page share one database snapshot. The page covers at most 200 uploads; each selected/unselected list shows at most 20 neutralized titles, 120 characters per title and a 1,200-character bullet budget, with the omitted count stated explicitly. These lists expose inventory, without widening the selected content scope.

Upload-state questions use live corpus tools rather than conversation history; the narrow recall override and its exceptions are defined in the intent guard reference.

Shared citation object​

Branch on kind when rendering citations. The base fields below describe a kind: "chunk" citation; conditional access-scope fields appear only when that source document is restricted.

FieldTypeRequiredMeaning
kind"chunk"YesIndexed document passage.
document_idstring (UUID)YesSource document ID.
document_titlestringYesSource document title.
chunk_idstring (UUID)YesSource chunk ID.
chunk_ordinalintegerYesChunk ordinal within the document.
page_numberinteger or nullYesSource page number when known.
section_pathstring arrayYesHierarchical section path for the chunk. Empty when no section path is known.
legal_referencestring or nullYesLegal citation/reference extracted for the chunk when known.
char_startinteger or nullYesStart character offset within the source text when known.
char_endinteger or nullYesEnd character offset within the source text when known.
confidentialtrueConditionalPresent only when the source document is confidential. Omitted for non-confidential sources.
owning_org_unitstringConditionalPresent only when the source has an owning organization unit restriction.
min_position_levelintegerConditionalPresent only when the source requires a minimum position level.

Example citation:

{
"kind": "chunk",
"document_id": "00000000-0000-0000-0000-000000000042",
"document_title": "Qanun Aceh Budget Transparency",
"chunk_id": "00000000-0000-0000-0000-000000000991",
"chunk_ordinal": 7,
"page_number": 12,
"section_path": ["Bab III", "Pasal 8"],
"legal_reference": "Qanun Aceh No. 1 Tahun 2024 Pasal 8",
"char_start": 1534,
"char_end": 2088,
"confidential": true,
"owning_org_unit": "Bappeda",
"min_position_level": 3
}

kind: "tool" identifies a tool read rather than an indexed chunk. It carries tool + call and null document/chunk IDs. Portal reads may also carry source_title, source_name, source_id, period, and source_url; document_title is the ready-to-render source label.

Successful catalogued workbook reads (peek, describe, compute, rows) carry the workbook title in source_title, its collection name in source_name (or Spreadsheet when absent), and spreadsheet:<document-id> in source_id. The workbook year supplies period when available; source_url is null. document_title renders that source identity, including the year when available. Workbook reads without a catalogue title and document ID retain the tool-name fallback. Error and miss replies do not receive workbook citation metadata.

kind: "history" identifies conversation provenance. It carries turn_index and null document_id / chunk_id, with no document title or section path. turn_index is the original zero-based user exchange index, not an offset in the bounded history window. A quotation of an assistant belongs to the exchange started by that user turn and proves prior speech, not the quoted corpus fact.

History records remain in /query.claim_map and stored messages[].metadata.synthesis.claims. Fresh flat /query.citations, SSE citation frames, and stored source_citations contain only chunk/tool sources. A verified pure-memory answer returns citations: [] with provenance_complete: true; mixed answers retain their chunk/tool sources. Older stored messages may still contain history in source_citations. Skip these records in document cards, source numbering, and copy/print endnotes.

kind: "memory" identifies a sentence that restates one of the caller's long-term memories (ADR-0047). It carries memory_id and null document_id / chunk_id; citation_key is "memory". Like history, it appears only in /query.claim_map and the stored claims, never in citations, SSE citation frames or source_citations. Render it, if at all, as "from what you told me earlier" and link to GET /memories, not as a document. It appears only when long-term memory is enabled.

POST /query JSON answer​

POST /query runs the full cited answer flow and returns one JSON object. It creates a new conversation when conversation_id is omitted. When conversation_id is present, the new user turn is appended to that existing conversation and recent conversation context is used to answer follow-up questions. Unknown conversations and conversations owned by another subject return 404.

Successful responses include Cache-Control: no-transform. Public gateways and other compliant intermediaries must forward the JSON representation without adding gzip, zstd, or another transformation.

A Government Policy Guard refusal is still a successful 200 JSON response: answer contains the refusal text, and citations may be empty or contain the sources used before the refusal decision. Use /query/stream when the UI needs a distinct guard.blocked event.

Retrieval reuse within one answer​

During one /query or /query/stream answer, search_documents retrieves each identical search arm once and reuses its ranked result. Concurrent tool calls share the same in-flight retrieval. The original-question arm runs the hybrid path without its keyword leg (semantic retrieval, document-label and follow-up nominations, rerank), preserving broad discovery alongside the agent's wording without a keyword search of the whole sentence. When no other arm of the call keeps a keyword leg, the original-question arm keeps its own.

Reuse requires identical formulation text, retrieval mode, principal/access fields, and collection/document scope, including exclusions and follow-up context. The store belongs to one answer: it is not shared across turns, conversations, users, or requests. Ranking, fusion, reranking, top-k, returned passages, and evidence de-duplication keep their existing behavior.

Failed, timed-out, or cancelled retrievals are not stored; a later call searches again. Cancelling one caller leaves retrieval running for remaining waiters; when no waiter remains, unfinished retrieval is cancelled and the caller returns without waiting for the cancelled read to unwind. The existing tool budget and timeout handling still applies. On the database server, the tool deadline bounds each statement of the lexical read and the single semantic statement to the time remaining (SET LOCAL statement_timeout, re-issued before every statement), and no further statement starts once the deadline has passed. The embedding and rerank calls are cancelled at the deadline, not bounded. A consumed arm emits a search_documents.arm Langfuse span with reused, even if a sibling arm later fails.

Documents indexed or changed mid-answer are not seen by an arm whose result is already stored. A different formulation can retrieve again, and a new answer starts with an empty store. Raw POST /search requests do not use this within-answer store. Offline coverage is in tests/unit/test_search_original_arm_reuse.py.

What the keyword search tells the agent about the match​

The agent is instructed to use several focused searches of one to four distinctive terms instead of one long conjunction, reusing the bounded legal-query planner.

search_documents reports the lexical tier's decision before the passages. All-words fill means that tier filled its candidate limit with passages containing every query term; the reported count is a lower bound. PARTIAL means only the reported N passages contained every term, so the tier fell short. NONE means no passage contained every term. In both shortfall cases, the any_word_or fallback supplied KEYWORD results, including passages matching only some terms. That fallback selects passages on the query's less common terms only: a term the database statistics place in at least 5% of passages by default (for example tahun or aceh) still ranks the passages it appears in but does not add passages on its own; when the less common terms match nothing, every term is used (ADR-0021). These labels do not describe term coverage or relevance of every fused result. The agent rewrites its query rather than treating partial matches as confirmation of the complete query. The response also states how many passages were returned, and marks any passage cut at the tool's snippet budget. If the planner omits the model's query as a bare instrument cue, or the backend supplies no tier decision, the response omits the match label rather than guessing it.

The retrieval itself is unchanged: the fallback still runs on the same trigger, against the same index, and the per-arm tier decision is still recorded in the answer trace (lexical_arms[]). Only what the agent is told about it is new.

/query/v2: searching across documents, and inside one​

On /query/v2 only, the agent may also call search_in_document(document_id, query). It searches one document that an earlier search for the same question already returned, keeping the caller's access and scope. Its first line says how many of that document's passages contain every term, so the agent can settle "does this regulation say X?" in one call instead of reading it article by article. A document_id that no search has returned is refused.

On /query/v2, a search counts as progress only when it returns a document not seen before for the same question. Two tool rounds in a row that bring nothing new end the search phase, and the agent answers with what it found and says which part is missing. A question scoped to selected documents keeps the earlier rule, where any new passage counts as progress, because reading those documents article by article is the job. That rule covers document search only: an OpenData or BPS miss still falls back to the documents. For a question about a topic in a place, the agent is asked to send two or three different searches in its first round (ADR-0051, amended 24 Oct 2026). A search that matched none of its terms is rewritten before the agent answers, even when another search in the same round matched (amended 25 Oct 2026). /query is unchanged.

/query/v2: when an indexed regulation revokes or amends a source​

A deploy can turn this on with ACEH_RAG_LEGAL_RELATIONS__SURFACE_ENABLED=true. It is off by default and applies to /query/v2 only (ADR-0053, Proposed). When an indexed regulation says it revokes, supersedes or amends a document a search returned, that passage's source identity carries legal_status (revoked, superseded or amended). It also carries a legal_status_basis that names who says so: "dicabut oleh <regulation> <Pasal> (sejak <date>)", "digantikan oleh …", or "diubah oleh … (ketentuan lain tetap berlaku)". The revoking clause, up to two per search, is added to the results as an ordinary citable passage. The agent attributes the status to that regulation and cites its clause. For what applies now it takes the rule from the newer regulation. For a past period it answers from the old one and says when it was revoked. Nothing is filtered or demoted. A document no indexed regulation speaks about stays legal_status="unknown". That is not a statement that it is in force. The response and citation shapes are unchanged.

Named regulations​

When a question names an instrument by type, number, and year, the answer service accepts forms such as Qanun Aceh Nomor 6 Tahun 2014 and Qanun 6/2014, including sentence-ending punctuation after the year. It resolves the primary citation at the start of an indexed title, including hyphenated or underscored title slugs, and rejects conflicting type, number, or year in a stored document-identity card. Amendments and implementing regulations that only reference the named regulation do not match its identity. Citation numbers are compared case-insensitively, including letter suffixes. Explicitly named jurisdictions distinguish instruments with the same number and year. Resolution requires one source identity (source_key, or document ID when absent) with exactly one non-superseded record. Reaching the four-match browse limit is treated as ambiguous rather than choosing a document.

Before the first agent request, the service uses the stored normative legal_structure card and indexed chunk references to retrieve explicit Pasal, ayat, and angka evidence. Locators belong to their own law's citation, whether the article comes before or after the law. Missing or partial matches, uncertain cross-law ownership, and partially parsed item lists or article ranges retain available exact evidence and trigger document-scoped search. Without an explicit locator, the question goes directly to document-scoped search. Selecting exactly one visible indexed document through document_ids uses the same retrieval path when the question names no instrument.

Both paths also retain bounded closing/signature evidence, including neighboring chunks for split signature blocks, alongside article or ranked evidence. Named documents retain separate signature evidence and citation labels even when their closing text is identical. Closing chunks are located in the database and only their spans are read, rather than materializing the entire document.

Unknown, ambiguous, or unindexed anchors and empty scoped searches preserve ordinary retrieval; an anchor miss alone is not a not-found answer. The identity lookup is bounded: a lookup that hits its row cap or exceeds its 10-second deadline also falls back to ordinary retrieval rather than choosing a document. All reads, including fallback, retain the caller's access, collection, and document scope. The model can still search when preloaded evidence is insufficient. If required evidence cannot fit the prompt budget, the service returns its bounded budget refusal. Fresh scoped anchors satisfy the current-turn evidence requirement without a model-selected search; retained conversation passages alone do not.

The corpus_facts tool accepts a case-insensitive literal title substring filter before aggregation, digit-shaped year strings, and recognized display-form instrument types such as Peraturan Gubernur Aceh. Signatory requests normalize to role-preserving groups, including requests using count/list/compare or an explicit signatory grouping. Roster coverage reports promulgation-card status and documents carrying readable officer names; missing or partial rosters are reported as lower bounds, and an entirely unreadable roster is unavailable. Ordinary document counts remain exact without requiring extracted fact cards. The offline comparison is reproducible with uv run python scripts/measure_named_anchor_retrieval.py; it runs the production answer service with a fake agent, compares ordered chunk identities and complete evidence text on one fixture, and fails on non-named changes. The baseline disables only the pre-agent anchor resolution.

Evidence too large before generation​

Required initial evidence that cannot fit returns HTTP 200 with fallback_reason="context_budget_exhausted", a fixed context-capacity notice, citations: [], and faithfulness_checked: false. The SSE faithfulness frame and stored metadata.synthesis.faithfulness use checked: false. Display this as verification not reached, not a rejected claim or a low-confidence generated answer. Missing/null checked values on other paths make no new verification claim. The existing false provenance_complete and true fallback_used values remain. True time/model-call exhaustion retains its existing timeout reason and notice.

Refusal metadata includes synthesis.prompt_budget: initial_passages, initial_source_chars, grounding_char_limit, the existing dynamic token pool, and trimmed/dropped counters. The initial source count excludes source labels; grounding_chars_used includes the framing actually recorded. See the partner display contract.

Agent output limit​

Every agent model round on /query and /query/stream has an output-token ceiling. A round that reaches it was cut short, so its text is never published: the response is HTTP 200 with fallback_reason="agent_output_limit", a fixed notice asking the reader to ask about one part of the question, citations: [] and fallback_used: true. A round whose input leaves too little of the model's window to write in is instead the context_budget_exhausted refusal described above. It is not a verdict on the documents, and retrying the same question gives the same result. The reason is distinct from query_budget_exhausted so operators can tell a runaway decode from a spent budget.

Document follow-ups​

On /query and /query/stream, a genuine follow-up that names no document can carry the preceding document into retrieval. For example, after asking who signed Peraturan Gubernur Aceh Nomor 60 Tahun 2013, “siapa sekretaris daerah aceh nya” still reaches that regulation's passages, including when the model writes a shorter search_documents query or retrieval expands it into several searches. The document is carried by id, not by title: every search runs the model's and the user's own wording, and each hybrid search ranks the inherited documents ahead of its own document-label matches, so the bounded label-scoped lookup can admit their passages. Appending the titles instead added about 60 lexemes on a six-title follow-up, which defeated the all-words lexical tier and sent the any-word tier across most of the corpus. Clients using the shared answer service, including Studio and partner clients, receive the same behavior.

For ordinary answers in caller-owned, unshared chats, the resolver sees bounded first and recent exchanges, not just the immediately preceding answer. Its strict verdict selects known exchange IDs; those exchanges' published citations bind current document IDs even when different documents have the same title. Inherited document ids are retrieval hints, not filters or access grants: the caller's access filter and source scope are rechecked on every passage they admit. Broad inherited title sets do not become mandatory named-document anchors. One unambiguous model-resolved document retains exact article and closing anchors; otherwise the current question (or explicit single-document scope) requests those anchors; that one title names the anchor document, but the anchor's searches run the question alone. Prior cited passages are restored separately; supplemental searches still nominate the selected documents. This prevents a broadly cited previous answer from forcing closing/signature scans across every cited regulation before generation.

Persisted chunk IDs and visible lengths rehydrate current indexed bodies, including read headers from published documents only within their persisted visible prefixes. Restoration never completes a source, chooses the longest prior version, or uses a public citation to enlarge a packet. Missing evidence requires fresh retrieval. Metadata's complete table snapshots do not bypass chunk eligibility.

The persisted metadata.synthesis.source_passages packet supplies document_id, chunk_id, and a positive integer visible_chars; missing or invalid bounds require fresh retrieval. Public citations supply identity hints only. Independently retrieved fresh anchors keep their current bodies under the existing budget. Exports and shared chats receive no restored packets; see the resume workflow for the memory scope and conversation-only recall.

New request-local labels preserve normal citation, faithfulness, and Policy Guard checks. Assistant prose and previously computed values are not corpus evidence.

Already sufficient source passages do not require another search. Supplemental searches retain the caller's collection/document union and exclusions; source reuse does not restrict every future search to the earlier document.

A separate self-hosted semantic judge must return a strict boolean true and known exchange IDs before sources are carried. First turns, questions naming their own document, and named document switches keep their searches unchanged. Independent questions such as “berapa persentase penduduk miskin Aceh 2024” should receive no carry; live-data tool keywords are not rewritten. The classifier has a five-second ceiling inside the shared query budget (90 seconds by default), and is skipped when too little time or too few model calls remain. Unavailable, malformed, nonboolean, or timed out classification leaves the original query unchanged.

Search tool audit entries in stored assistant-message metadata.synthesis.tool_call_audit retain proposed_arguments and executed_arguments (now identical: no search query is rewritten), plus followup_document_ids when the follow-up carried documents. At most six documents are carried, most recent exchange first: round-2 follow-ups never carried more, and each extra one lowers the query's own label hits. The answer trace's followup_documents records each one, whether it reached the rerank pool, and whether it was cited (ADR-0046). This does not add a top-level query response field. Hermetic coverage is in tests/unit/test_followup_document_referent.py and tests/unit/test_followup_source_continuity.py; it does not measure live-model classification or answer quality. This is bounded source continuity and record recall, not general semantic memory or autocompaction.

Answering figure and count questions​

The request and response shape is unchanged, but the answer path handles figure-, count-, and quantity-shaped questions more directly than before:

  • The faithfulness check that runs before an answer is returned is figure-aware (#209, #216). Numeric answers — short numbers, dates, and figures that appear differently across languages — are no longer dropped by a naive word-overlap check, so a grounded figure question is answered and cited rather than over-refused.
  • The Planner selects the read-only SQL Query tool only when a structured data source is actually configured for the deployment (#223). When no structured source is configured, a quantity-shaped question is answered from the document corpus by hybrid retrieval instead of being routed to an unavailable tool — so it returns a cited answer rather than a refusal.
  • OpenData and SatuData figures are read query-specifically from allow-listed CKAN portals and grounded with dataset identity plus read time.
  • BPS Statistics reads authoritative indicators from the allow-listed BPS WebAPI when the BPS feature is enabled and keyed.

These are answer-quality behaviors inside the pipeline. They do not add or rename request fields. OpenData/SatuData and BPS values are live tool groundings only when their feature flags, allow-lists, and (for BPS) application key are configured; otherwise the tool returns an honest unavailable/not-enabled note.

Request fields​

FieldTypeRequiredConstraintsMeaning
querystringYesMinimum length 1 at JSON validation: "" returns 422; non-empty whitespace-only strings return 400.User question.
user_idstring (UUID)NoMust be a valid UUID when supplied; defaults to the all-zero sentinel UUID.Development fallback subject only when trusted auth is not required; integrated clients should rely on trusted principal headers.
conversation_idstring (UUID) or nullNoMust be non-empty when supplied.Existing conversation to resume. Omit or set null to start a fresh conversation.
collection_idsstring array or nullNoOmitted, null, [], or an array that normalizes to no nonblank IDs means all accessible collections; nonblank IDs are trimmed and deduplicated before scoping.Optional retrieval scope.
document_idsUUID string array or nullNoOmitted, null, or [] means all accessible documents. Each supplied value must be a valid UUID; values are canonicalized and deduplicated before scoping.Optional selected-document retrieval focus; intersects with collection_ids and access filtering.

Request JSON​

{
"query": "Ringkas aturan transparansi anggaran Aceh dan beri sumbernya.",
"user_id": "00000000-0000-0000-0000-000000000001",
"conversation_id": null,
"collection_ids": ["JDIH", "PPID"],
"document_ids": ["00000000-0000-0000-0000-000000000042"]
}

Response fields​

FieldTypeMeaning
answerstringFinal guarded answer or guard refusal text.
conversation_idstringConversation ID for follow-up turns; timeout persistence exceptions follow ADR-0041.
message_idstringStored assistant message ID; timeout persistence exceptions follow ADR-0041.
trace_idstring or nullOpaque 32-character Langfuse trace ID stored with the assistant message. null means tracing was disabled or unavailable; the answer remains valid.
history_truncatedbooleantrue when some prior user exchanges are absent from the history window used for this answer. Derived from the stored exchange ordinals and actual row, character, and prompt-token trimming, including later evidence eviction and any retained first exchange.
history_exchanges_in_viewoptional non-negative integerNumber of prior question/answer pairs remaining in that window. Excludes the current question/answer; zero for a new chat. Refusals before prompt planning report the window actually loaded under the row and character caps. This is not the configured maximum. If history loading has not completed, the count is absent (never null) and history_truncated is false.
citationscitation arraySource citations for the answer. See Shared citation object.
claim_mapobject arraySurviving claims and their labelled citation references, including conversation provenance; see Shared citation object.
provenance_completebooleanVerification verdict for the published factual text; see the faithfulness gate contract. An empty flat citation list alone does not imply incomplete provenance.
fallback_usedbooleanWhether the faithfulness/no-answer path used a fallback answer.
fallback_reasonstring | nullPublication reason; partial_answer is an opt-in published answer with fallback_used=false, distinct from a complete answer (null) and a refusal (production renders partial answers OFF — ACEH_RAG_SYNTHESIS__PARTIAL_ANSWERS_ENABLED=false, deployed ref 2cc9a417). The partial answer text includes the omitted-parts note. See the authoritative reason table and QueryResponse in src/aceh_rag/api/app.py for the full contract.
dropped_claims_countintegerNumber of unsupported claims removed before the guarded answer was returned.
claims_supportedintegerClaims the gate verified against a source passage, counted even when the answer is later withheld or refused.
claims_totalintegerclaims_supported plus the claims that failed against a source: every claim checked. Not claims_supported + dropped_claims_count; see Claim grounding rate.
grounding_ratenumber | nullClaim grounding rate: claims_supported / claims_total, from 0 to 1 at four decimals; null when no claim was checked. See Claim grounding rate.

Claim grounding rate​

grounding_rate is the share of the answer's checked claims that a cited source supports: the claims the gate verified, out of those plus the claims that failed against a source. Show it as a percentage with the counts, for example "78% · 7/9 klaim" (Bahasa Indonesia label: Tingkat keterverifikasian klaim). When it is null nothing was checked (no_claims, no_answer, empty_completion, a refusal before the gate): show "—", never 0%. A claim that restates the user's earlier turn or a stored memory is verified against no source, so it is in neither count, whether kept or dropped. An unused verbatim excerpt the gate drops (surplus_excerpt) failed nothing, so it is not counted either; dropped_claims_count still includes both, which is why claims_total can be less than claims_supported + dropped_claims_count. The rate describes what the gate checked, not what is shown: when an answer is withheld (coverage_incomplete) or replaced by a Policy Guard refusal, the claims the gate verified still count, so the rate reads "1 of 2 verified" rather than 0. A client may hide the badge on a refusal; the quality review reads it as root-cause evidence.

It is a per-answer runtime signal, not the faithfulness figure. The client's faithfulness numbers come from the independent claim-level evaluation pass (ADR-0042). The same three fields are on the SSE faithfulness frame, on /query/v2, and stored in metadata.synthesis.faithfulness of the assistant message.

:::note Contract change (3 Nov 2026)

claims_supported, claims_total and grounding_rate are additive: no existing field changed.

:::

When history_truncated is true, show a separate notice beside the answer asking the user to restate earlier points or start a new chat. Use history_exchanges_in_view for the visible-question count; do not hard-code five. The count can include a pinned first exchange, so it need not describe the contiguous last N questions. These fields are display metadata, not answer text, citations, a memory summary, or a faithfulness verdict.

With the default ten-message history cap, the sixth answer can still see all five prior exchanges; the seventh normally sees five of six and reports truncation. Character and token caps can make the notice appear sooner. Raising the server's history caps changes the notice without a frontend edit.

Unlabelled answers are not automatically published. no_claims now means the model confirmed that the answer asserts nothing needing source verification; it is shown and marked. Factual unlabelled answers are checked against the shown citable passages and published with collective citations only when supported. unverified_no_claims withholds the original text when classification or support fails, including judge unavailability. Missing passage labels never skip verification.

unparseable_completion withholds invalid model output; the raw failure evidence stays private, including on conversation replay. A format refusal prevents new artifact creation and attachment for that turn. unresolved_citation withholds an answer whose claims have no resolvable provenance. Studio explains both reasons and unverified_no_claims in Bahasa Indonesia and English. The gate reference owns the structural boundary; its module docstring defines the prose/code rule. That reference also owns the evidence-backed native output contract and the no-evidence text path. The inline answer contract owns the labelled prose and exact citable-row rules.

Response JSON​

{
"answer": "Aturan transparansi anggaran mewajibkan publikasi informasi anggaran dan pelaporan yang dapat diakses masyarakat [chunk:00000000-0000-0000-0000-000000000991].",
"conversation_id": "00000000-0000-0000-0000-000000000123",
"message_id": "00000000-0000-0000-0000-000000000456",
"trace_id": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
"history_truncated": false,
"history_exchanges_in_view": 0,
"citations": [
{
"document_id": "00000000-0000-0000-0000-000000000042",
"document_title": "Qanun Aceh Budget Transparency",
"chunk_id": "00000000-0000-0000-0000-000000000991",
"chunk_ordinal": 7,
"page_number": 12,
"section_path": ["Bab III", "Pasal 8"],
"legal_reference": "Qanun Aceh No. 1 Tahun 2024 Pasal 8",
"char_start": 1534,
"char_end": 2088
}
],
"fallback_used": false,
"fallback_reason": null,
"dropped_claims_count": 0,
"claims_supported": 1,
"claims_total": 1,
"grounding_rate": 1.0
}

curl​

curl --fail-with-body \
--request POST \
"$API_BASE/query" \
--header "X-API-Key: $API_KEY" \
--header "Content-Type: application/json" \
--data '{"query":"Apa ibu kota Provinsi Aceh?"}'

POST /query/stream Server-Sent Events​

POST /query/stream accepts the same request body as /query and has the same setup status/error behavior before the first progress frame. Tool progress can arrive while the agent runs; generated answer text is revealed only after faithfulness and Government Policy Guard checks complete.

Answer text uses buffer-then-reveal:

  • No unapproved draft text is streamed.
  • answer.delta frames are emitted only after the guard passes.
  • If the guard blocks, no answer.delta frame is emitted.
  • tool.started and tool.finished can precede message.start. For create_artifact, progress exposes only call, tool, and, on completion, ok and ms. Arguments, content, claims, and result/refusal summaries are withheld at the observer boundary, including for saved artifacts. Artifact results are internal generation feedback and do not become grounding or passage frames. Other tools retain their structured arguments and result summaries.
  • There are no heartbeat frames; allow for long gaps between progress events.
  • Setup failures return normal HTTP JSON errors before the text/event-stream body starts.

Request fields and JSON​

The request schema is identical to POST /query.

{
"query": "Apa ketentuan lanjutan dari aturan itu?",
"user_id": "00000000-0000-0000-0000-000000000001",
"conversation_id": "00000000-0000-0000-0000-000000000123",
"collection_ids": ["JDIH"],
"document_ids": ["00000000-0000-0000-0000-000000000042"]
}

Response headers​

Successful streams return 200 with Content-Type: text/event-stream. The server also sets Cache-Control: no-cache, no-transform, X-Accel-Buffering: no, and Connection: keep-alive to prevent intermediary buffering and transformations such as compression.

Huawei APIG or any other gateway in front of the API must pass text/event-stream responses through immediately, without compression or buffering. The production Caddy edge excludes /query and /query/* from response compression. Gateway timeouts must allow the full multi-step answer pipeline, terminal refusal handling, and gaps between progress frames.

SSE frame format​

Each event is framed as:

event: <event-name>
data: <single-line JSON object>

The data: payload is JSON. Clients should parse events in wire order and should not reorder citations or deltas.

Event order and terminal semantics​

A successful stream follows exactly one of these paths:

message.start
trace optional
citation zero or more times
passage zero or more times
answer.delta zero or more times
faithfulness
done

or, when the Government Policy Guard blocks the generated answer:

message.start
trace optional
citation zero or more times
guard.blocked
done

Either path can begin with live tool.started / tool.finished events. A failure after streaming starts sends terminal error instead of done; treat it as terminal and stop reading.

EventData shapeWhen it appearsClient behavior
tool.started / tool.finishedProgress object with call, tool; completion adds ok, ms. Artifact progress omits arguments and result text.While tools run, before message.start.Update progress; do not treat ok as a publication verdict.
message.start{"conversation_id": string, "message_id": string}After live progress, if any.Store IDs for the current assistant message and future resume, subject to the timeout persistence contract.
trace{"tools": array, "completed": true}After message.start when tools were used.Show the completed tool trace.
citationCitation objectZero or more times after message.start and before answer or block events.Attach citations to the pending assistant message in received order.
answer.delta{"text": string}Zero or more times only when the guard passes.Append text to the visible answer buffer.
passageGrounding object with kind and text; chunk passages include chunk_id, source_type, score; tool passages include tool, source_url, the full citation object (including call identity), and citation_key (the passage label).Before deltas, only when the guard passes.Attach supporting passages to citations by identity and label; a shared source URL alone cannot distinguish repeated tool invocations.
faithfulness{"dropped": number, "claims_supported": number, "claims_total": number, "grounding_rate": number or null, "fallback_used": boolean, "fallback_reason": string or null}After deltas, only when the guard passes.Show gate metadata and the claim grounding rate; dropped claim text is withheld.
guard.blocked{"reason": string}Instead of answer.delta when the guard blocks.Show a refusal/block state using reason; do not render an answer buffer.
done{"finish_reason": "stop" or "guard_blocked", "history_truncated": boolean, "history_exchanges_in_view"?: integer}Normal terminal event, including guard blocks and timeout refusals.Finalize the message and show a separate history-window notice when flagged; see the JSON response fields above.
error{"code": string, "message": string}Terminal failure after streaming starts; codes include query_failed, http_<status>, and stream_failed.Surface an error and stop; do not wait for done.

Guarded answer frames (excerpt)​

event: message.start
data: {"conversation_id":"00000000-0000-0000-0000-000000000123","message_id":"00000000-0000-0000-0000-000000000457"}

event: citation
data: {"document_id":"00000000-0000-0000-0000-000000000042","document_title":"Qanun Aceh Budget Transparency","chunk_id":"00000000-0000-0000-0000-000000000991","chunk_ordinal":7,"page_number":12,"section_path":["Bab III","Pasal 8"],"legal_reference":"Qanun Aceh No. 1 Tahun 2024 Pasal 8","char_start":1534,"char_end":2088}

event: answer.delta
data: {"text":"Aturan transparansi anggaran mewajibkan publikasi informasi anggaran"}

event: answer.delta
data: {"text":" dan pelaporan yang dapat diakses masyarakat [chunk:00000000-0000-0000-0000-000000000991]."}

event: done
data: {"finish_reason":"stop","history_truncated":false,"history_exchanges_in_view":0}

Guard-blocked SSE transcript​

event: message.start
data: {"conversation_id":"00000000-0000-0000-0000-000000000123","message_id":"00000000-0000-0000-0000-000000000458"}

event: guard.blocked
data: {"reason":"Permintaan tidak dapat dijawab karena melanggar kebijakan."}

event: done
data: {"finish_reason":"guard_blocked","history_truncated":false,"history_exchanges_in_view":0}

curl​

Use curl --no-buffer to avoid client-side output buffering.

curl --no-buffer --fail-with-body \
--request POST \
"$API_BASE/query/stream" \
--header "X-API-Key: $API_KEY" \
--header "Content-Type: application/json" \
--header "Accept: text/event-stream" \
--data '{"query":"Apa ibu kota Provinsi Aceh?"}'

POST /search raw retrieval​

POST /search returns ranked source chunks. It does not synthesize an answer, does not apply the generated-answer guard, and should not be shown to end users as if it were a chat answer. Use it for retrieval inspection, source pickers, citation previews, and debugging search relevance.

No matches is a successful 200 with results: []. This includes a well-formed document_ids value that is unknown, forbidden, inaccessible, or has no chunks matching the query; do not infer document existence or permissions from an empty result.

Request fields​

FieldTypeRequiredConstraintsMeaning
querystringYesMinimum length 1 at JSON validation: "" returns 422; non-empty whitespace-only strings return 400.Search text.
modestringNoOne of hybrid, semantic, lexical. Default hybrid.Retrieval strategy.
top_kintegerNoDefault 5; must be 1 through 50.Maximum number of chunks returned after ranking.
collection_idsstring array or nullNoOmitted, null, [], or an array that normalizes to no nonblank IDs means all accessible collections; nonblank IDs are trimmed and deduplicated before scoping.Optional retrieval scope.
document_idsUUID string array or nullNoOmitted, null, or [] means all accessible documents. Each supplied value must be a valid UUID; values are canonicalized and deduplicated before scoping.Optional selected-document retrieval focus; intersects with collection_ids and access filtering.

Search modes​

ModeBehaviorUse when
hybridCombines semantic vector retrieval and lexical PostgreSQL full-text retrieval, then ranks the fused candidates.Default for user-facing retrieval quality.
semanticUses only semantic vector retrieval.You need meaning-based matches without lexical fusion.
lexicalUses only full-text lexical retrieval.You need exact term/statute/string matching behavior.

Request JSON​

{
"query": "transparansi anggaran",
"mode": "hybrid",
"top_k": 5,
"collection_ids": ["JDIH", "PPID"],
"document_ids": ["00000000-0000-0000-0000-000000000042"]
}

Response fields​

FieldTypeMeaning
modestringThe mode used for the request.
resultssearch result arrayRanked chunks. Empty when there are no visible matches.

Search result object:

FieldTypeMeaning
chunk_idstring (UUID)Source chunk ID. Mirrors citation.chunk_id.
document_idstring (UUID)Source document ID. Mirrors citation.document_id.
ordinalintegerChunk ordinal within the document.
textstringRetrieved chunk text.
scorenumberRetrieval score for ranking. Higher-ranked results appear earlier. Do not compare scores across different modes as a stable API contract.
citationcitation objectFull citation metadata. See Shared citation object.

Response JSON​

{
"mode": "hybrid",
"results": [
{
"chunk_id": "00000000-0000-0000-0000-000000000991",
"document_id": "00000000-0000-0000-0000-000000000042",
"ordinal": 7,
"text": "Pemerintah Aceh mempublikasikan informasi anggaran kepada masyarakat...",
"score": 0.8732,
"citation": {
"document_id": "00000000-0000-0000-0000-000000000042",
"document_title": "Qanun Aceh Budget Transparency",
"chunk_id": "00000000-0000-0000-0000-000000000991",
"chunk_ordinal": 7,
"page_number": 12,
"section_path": ["Bab III", "Pasal 8"],
"legal_reference": "Qanun Aceh No. 1 Tahun 2024 Pasal 8",
"char_start": 1534,
"char_end": 2088
}
}
]
}

curl​

curl --fail-with-body \
--request POST \
"$API_BASE/search" \
--header "X-API-Key: $API_KEY" \
--header "Content-Type: application/json" \
--data '{"query":"Apa ibu kota Provinsi Aceh?"}'

Status and error behavior​

Standard error responses are JSON objects:

{"detail":"Invalid or missing API key"}

FastAPI request-validation errors use the standard 422 shape with detail as an array of validation items.

{
"detail": [
{
"type": "greater_than",
"loc": ["body", "top_k"],
"msg": "Input should be greater than 0",
"input": 0,
"ctx": {"gt": 0}
}
]
}
StatusApplies toMeaningBody shape
200/queryAnswer returned. A guard refusal is still 200 with the refusal in answer.QueryResponse
200/query/streamSSE stream started after setup completed. The stream terminates with done or error.text/event-stream
200/searchSearch completed, including no matches.SearchResponse with results: [] when empty
400All three routesNon-empty query string is whitespace-only after parsing.{"detail":"Query must not be empty"}
401All three routesAPI key missing/invalid, required principal missing, or malformed principal when trusted auth is required.{"detail":"Invalid or missing API key"}, {"detail":"Authenticated principal required"}, or {"detail":"Invalid authenticated principal"}
404/query, /query/streamConversation ID is unknown or belongs to another subject; or no visible indexed content is available to answer, including when a well-formed document focus yields no matching chunks.{"detail":"..."}
413/query, /query/streamThe answer context would exceed the model context window.{"detail":"..."}
422All three routesJSON schema validation failed, such as query: "", invalid enum, malformed document_ids entry, conversation_id: "", top_k outside 1..50, missing query, or extra fields on /search.{"detail":[...]}
502/query, /query/streamModel or orchestration dependency failed; for timeout and budget refusals, see ADR-0041.{"detail":"..."}
502/searchModel dependency failed, such as an embedding or reranker ModelClientError. Unhandled repository faults are not mapped by this handler.{"detail":"..."} for model dependency failures

For /query/stream, failures before the first progress frame (400, 401, 404, 413, 422, 502) return HTTP JSON errors. Once a 200 text/event-stream body starts, normal completion is represented by done; a pipeline or framing failure is represented by terminal error instead of done.

Production TypeScript client​

These helpers are intended for a trusted server, BFF, or same-origin route that can read the API key from server-side configuration. Do not ship the shared API key to browser JavaScript.

type SearchMode = "hybrid" | "semantic" | "lexical";

type Citation = {
document_id: string;
document_title: string;
chunk_id: string;
chunk_ordinal: number;
page_number: number | null;
section_path: string[];
legal_reference: string | null;
char_start: number | null;
char_end: number | null;
confidential?: true;
owning_org_unit?: string;
min_position_level?: number;
};

type QueryRequest = {
query: string;
user_id?: string;
conversation_id?: string | null;
collection_ids?: string[] | null;
document_ids?: string[] | null;
};

type QueryResponse = {
answer: string;
conversation_id: string;
message_id: string;
trace_id: string | null;
history_truncated: boolean;
history_exchanges_in_view?: number;
citations: Citation[];
fallback_used: boolean;
fallback_reason: string | null;
dropped_claims_count: number;
claims_supported: number;
claims_total: number;
grounding_rate: number | null;
};

type SearchRequest = {
query: string;
mode?: SearchMode;
top_k?: number;
collection_ids?: string[] | null;
document_ids?: string[] | null;
};

type SearchResult = {
chunk_id: string;
document_id: string;
ordinal: number;
text: string;
score: number;
citation: Citation;
};

type SearchResponse = {
mode: SearchMode;
results: SearchResult[];
};

type ApiValidationItem = {
type?: string;
loc?: Array<string | number>;
msg?: string;
input?: unknown;
ctx?: Record<string, unknown>;
};

type ApiErrorPayload = { detail: string | ApiValidationItem[] };

class AcehApiError extends Error {
readonly status: number;
readonly detail: string | ApiValidationItem[] | undefined;

constructor(status: number, detail: string | ApiValidationItem[] | undefined) {
super(renderApiDetail(status, detail));
this.name = "AcehApiError";
this.status = status;
this.detail = detail;
}
}

type ClientOptions = {
baseUrl: string;
apiKey: string;
apiKeyHeader?: string;
principalHeaders?: {
subject: string;
role?: string;
unit?: string;
level?: number;
};
fetchImpl?: typeof fetch;
};

function apiHeaders(options: ClientOptions, accept?: string): Headers {
const headers = new Headers();
headers.set("Content-Type", "application/json");
if (accept) headers.set("Accept", accept);
headers.set(options.apiKeyHeader ?? "X-API-Key", options.apiKey);

const principal = options.principalHeaders;
if (principal) {
headers.set("X-User-Subject", principal.subject);
if (principal.role) headers.set("X-User-Role", principal.role);
if (principal.unit) headers.set("X-User-Unit", principal.unit);
if (principal.level !== undefined) headers.set("X-User-Level", String(principal.level));
}

return headers;
}

function endpoint(baseUrl: string, path: string): string {
return new URL(path, baseUrl.endsWith("/") ? baseUrl : `${baseUrl}/`).toString();
}

async function readError(response: Response): Promise<AcehApiError> {
let detail: ApiErrorPayload["detail"] | undefined;
try {
const payload = (await response.json()) as Partial<ApiErrorPayload>;
detail = payload.detail;
} catch {
detail = response.statusText || undefined;
}
return new AcehApiError(response.status, detail);
}

function renderApiDetail(status: number, detail: ApiErrorPayload["detail"] | undefined): string {
if (typeof detail === "string" && detail.trim()) return `API ${status}: ${detail}`;
if (Array.isArray(detail)) return `API ${status}: ${JSON.stringify(detail)}`;
return `API ${status}`;
}

async function postJson<TResponse>(
options: ClientOptions,
path: string,
body: unknown,
signal?: AbortSignal,
): Promise<TResponse> {
const fetcher = options.fetchImpl ?? fetch;
const response = await fetcher(endpoint(options.baseUrl, path), {
method: "POST",
headers: apiHeaders(options),
body: JSON.stringify(body),
signal,
});

if (!response.ok) throw await readError(response);
return (await response.json()) as TResponse;
}

export async function queryJson(
options: ClientOptions,
request: QueryRequest,
signal?: AbortSignal,
): Promise<QueryResponse> {
return postJson<QueryResponse>(options, "/query", request, signal);
}

export async function searchRaw(
options: ClientOptions,
request: SearchRequest,
signal?: AbortSignal,
): Promise<SearchResponse> {
return postJson<SearchResponse>(options, "/search", request, signal);
}

TypeScript SSE parsing with fetch() and ReadableStream​

type QueryStreamEvent =
| { event: "message.start"; data: { conversation_id: string; message_id: string } }
| { event: "citation"; data: Citation }
| { event: "answer.delta"; data: { text: string } }
| { event: "guard.blocked"; data: { reason: string } }
| {
event: "done";
data: { finish_reason: "stop" | "guard_blocked" } &
Pick<QueryResponse, "history_truncated" | "history_exchanges_in_view">;
}
| { event: "error"; data: { code: string; message: string } };

type QueryStreamCallbacks = {
onStart?: (data: { conversation_id: string; message_id: string }) => void;
onCitation?: (citation: Citation) => void;
onDelta?: (text: string) => void;
onBlocked?: (reason: string) => void;
onDone?: (data: Extract<QueryStreamEvent, { event: "done" }>["data"]) => void;
onError?: (error: Error) => void;
signal?: AbortSignal;
};

export async function streamQuery(
options: ClientOptions,
request: QueryRequest,
callbacks: QueryStreamCallbacks,
): Promise<{ conversationId: string; messageId: string; answer: string; citations: Citation[]; blockedReason?: string }> {
const fetcher = options.fetchImpl ?? fetch;
const response = await fetcher(endpoint(options.baseUrl, "/query/stream"), {
method: "POST",
headers: apiHeaders(options, "text/event-stream"),
body: JSON.stringify(request),
signal: callbacks.signal,
});

if (!response.ok) throw await readError(response);
if (!response.body) throw new Error("Streaming response did not include a body");

const contentType = response.headers.get("content-type") ?? "";
if (!contentType.toLowerCase().includes("text/event-stream")) {
throw new Error(`Expected text/event-stream but received ${contentType || "no content-type"}`);
}

const reader = response.body.getReader();
const decoder = new TextDecoder();
let buffer = "";
let conversationId: string | undefined;
let messageId: string | undefined;
let answer = "";
let blockedReason: string | undefined;
const citations: Citation[] = [];

const handleEvent = (frame: QueryStreamEvent): boolean => {
switch (frame.event) {
case "message.start":
conversationId = frame.data.conversation_id;
messageId = frame.data.message_id;
callbacks.onStart?.(frame.data);
return false;
case "citation":
citations.push(frame.data);
callbacks.onCitation?.(frame.data);
return false;
case "answer.delta":
answer += frame.data.text;
callbacks.onDelta?.(frame.data.text);
return false;
case "guard.blocked":
blockedReason = frame.data.reason;
callbacks.onBlocked?.(frame.data.reason);
return false;
case "done":
callbacks.onDone?.(frame.data);
return true;
case "error": {
const error = new Error(`${frame.data.code}: ${frame.data.message}`);
callbacks.onError?.(error);
throw error;
}
}
};

try {
for (;;) {
const { value, done } = await reader.read();
buffer += decoder.decode(value ?? new Uint8Array(), { stream: !done });
buffer = buffer.replace(/\r\n/g, "\n");

let boundary: number;
while ((boundary = buffer.indexOf("\n\n")) !== -1) {
const rawFrame = buffer.slice(0, boundary);
buffer = buffer.slice(boundary + 2);
if (!rawFrame.trim() || rawFrame.startsWith(":")) continue;

const parsed = parseSseFrame(rawFrame);
if (!parsed) continue;
const terminal = handleEvent(parsed);
if (terminal) {
await reader.cancel().catch(() => undefined);
if (conversationId === undefined || messageId === undefined) {
throw new Error("Stream completed without message.start");
}
return { conversationId, messageId, answer, citations, blockedReason };
}
}

if (done) break;
}
} finally {
reader.releaseLock();
}

throw new Error("SSE stream ended before done or error");
}

function parseSseFrame(rawFrame: string): QueryStreamEvent | null {
let eventName = "message";
const dataLines: string[] = [];

for (const line of rawFrame.split("\n")) {
if (!line || line.startsWith(":")) continue;
if (line.startsWith("event:")) {
eventName = line.slice("event:".length).trim();
} else if (line.startsWith("data:")) {
dataLines.push(line.slice("data:".length).trimStart());
}
}

if (dataLines.length === 0) return null;
const data = JSON.parse(dataLines.join("\n")) as unknown;

switch (eventName) {
// This minimal answer renderer ignores progress and supporting metadata.
case "tool.started":
case "tool.finished":
case "trace":
case "passage":
case "faithfulness":
return null;
case "message.start":
case "citation":
case "answer.delta":
case "guard.blocked":
case "done":
case "error":
return { event: eventName, data } as QueryStreamEvent;
default:
throw new Error(`Unexpected SSE event: ${eventName}`);
}
}

A chat UI can render cited streaming answers by collecting citation events, appending answer.delta.text, replacing the answer area with guard.blocked.reason on a block, and treating either done or error as terminal.