Query, Streaming Query, and Search APIs
Use these endpoints to build cited chat and raw retrieval clients:
| Endpoint | Purpose | Response type |
|---|---|---|
POST /query | Run the full answer pipeline and return one cited answer as JSON. | application/json |
POST /query/stream | Run the same answer pipeline, then reveal the already-guarded result as Server-Sent Events. | text/event-stream |
POST /search | Return ranked source chunks only, with no answer synthesis. | application/json |
All routes are mounted at the API root. The public review-staging Caddy edge proxies the complete FastAPI HTTP surface, including /healthz, /openapi.json, and the non-production /docs UI. 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 Redoc route and 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:
| Header | Type | Meaning |
|---|---|---|
X-User-Subject | string | Stable authenticated user subject. |
X-User-Role | string | Authenticated role. Defaults to user only when the trusted resolver accepts fallback behavior. |
X-User-Unit | string | Optional organization unit for access filtering. |
X-User-Level | integer string | Optional 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 review-staging edge, clients send only X-API-Key. Caddy removes every caller-supplied X-User-* header and injects the fixed staging identity before proxying the request. 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.reviewstagepro.com'
read -rsp 'Staging 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 staging 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.
| Value | Behavior |
|---|---|
| Field omitted | Search all collections the caller may access. |
null | Same as omitted. |
[] | Same as omitted. |
| String array | Trim 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 strings | Same as omitted: after normalization there is no collection scope, so all accessible collections are searched. |
| Nonblank unknown or forbidden ID | Produces 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.
Shared citation object
Every citation has the base fields below. The conditional access-scope fields appear only when that source document is restricted.
| Field | Type | Required | Meaning |
|---|---|---|---|
document_id | integer | Yes | Source document ID. |
document_title | string | Yes | Source document title. |
chunk_id | integer | Yes | Source chunk ID. |
chunk_ordinal | integer | Yes | Chunk ordinal within the document. |
page_number | integer or null | Yes | Source page number when known. |
section_path | string array | Yes | Hierarchical section path for the chunk. Empty when no section path is known. |
legal_reference | string or null | Yes | Legal citation/reference extracted for the chunk when known. |
char_start | integer or null | Yes | Start character offset within the source text when known. |
char_end | integer or null | Yes | End character offset within the source text when known. |
confidential | true | Conditional | Present only when the source document is confidential. Omitted for non-confidential sources. |
owning_org_unit | string | Conditional | Present only when the source has an owning organization unit restriction. |
min_position_level | integer | Conditional | Present only when the source requires a minimum position level. |
Example citation:
{
"document_id": 42,
"document_title": "Qanun Aceh Budget Transparency",
"chunk_id": 991,
"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
}
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.
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.
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.
- Open Data figures are read query-specifically — each lookup is scoped to the specific question rather than pulling a whole dataset (#226) — and this live-portal path now covers Satu Data sources in addition to Open Data (#198).
These are answer-quality behaviors inside the pipeline. They do not add or rename any request or response field, and every returned figure is still grounded in a retrieved source and carries the usual citations.
Request fields
| Field | Type | Required | Constraints | Meaning |
|---|---|---|---|---|
query | string | Yes | Minimum length 1 at JSON validation: "" returns 422; non-empty whitespace-only strings return 400. | User question. |
user_id | string | No | Minimum length 1. Default anonymous. | Development fallback subject only when trusted auth is not required. Do not use it as browser-controlled identity in production. |
conversation_id | integer or null | No | Must be greater than 0 when set. | Existing conversation to resume. Omit or set null to start a fresh conversation. |
collection_ids | string array or null | No | Omitted, 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. |
Request JSON
{
"query": "Ringkas aturan transparansi anggaran Aceh dan beri sumbernya.",
"user_id": "frontend-dev-user",
"conversation_id": null,
"collection_ids": ["JDIH", "PPID"]
}
Response fields
| Field | Type | Meaning |
|---|---|---|
answer | string | Final guarded answer or guard refusal text. |
conversation_id | integer | Conversation that now contains the user turn and assistant message. Store it for follow-up turns. |
message_id | integer | Stored assistant message ID. |
citations | citation array | Source citations for the answer. See Shared citation object. |
Response JSON
{
"answer": "Aturan transparansi anggaran mewajibkan publikasi informasi anggaran dan pelaporan yang dapat diakses masyarakat [chunk:991].",
"conversation_id": 123,
"message_id": 456,
"citations": [
{
"document_id": 42,
"document_title": "Qanun Aceh Budget Transparency",
"chunk_id": 991,
"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/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. The answer pipeline completes before the response body starts. Only after the final answer has passed guard checks does the API reveal content as SSE frames.
This is a buffer-then-reveal stream:
- No unapproved draft text is streamed.
answer.deltaframes are emitted only after the guard passes.- If the guard blocks, no
answer.deltaframe is emitted. - There are no heartbeat frames. The stream is a fast reveal after pre-stream processing finishes.
- Setup failures return normal HTTP JSON errors before the
text/event-streambody starts.
Request fields and JSON
The request schema is identical to POST /query.
{
"query": "Apa ketentuan lanjutan dari aturan itu?",
"user_id": "frontend-dev-user",
"conversation_id": 123,
"collection_ids": ["JDIH"]
}
Response headers
Successful streams return 200 with Content-Type: text/event-stream. The server also sets streaming-friendly headers including Cache-Control: no-cache, X-Accel-Buffering: no, and Connection: keep-alive.
Huawei APIG or any other gateway in front of the API must be configured not to buffer text/event-stream responses. Because work happens before streaming starts, gateway request/response timeouts must also be long enough for the full multi-step answer pipeline. Heartbeats would not protect this pre-stream window.
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
citation zero or more times
answer.delta zero or more times
done
or, when the Government Policy Guard blocks the generated answer:
message.start
citation zero or more times
guard.blocked
done
A terminal error event is reserved for an unexpected framing fault after streaming has started. In that case, error replaces done; treat it as terminal and stop reading.
| Event | Data shape | When it appears | Client behavior |
|---|---|---|---|
message.start | {"conversation_id": integer, "message_id": integer} | First event. | Store IDs for the current assistant message and future resume. |
citation | Citation object | Zero 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. |
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 {"finish_reason": "guard_blocked"} | Normal terminal event. | Finalize the message. |
error | {"code": "stream_failed", "message": string} | Terminal event only on mid-stream framing fault. | Surface an error and stop; do not wait for done. |
Normal SSE transcript
event: message.start
data: {"conversation_id":123,"message_id":457}
event: citation
data: {"document_id":42,"document_title":"Qanun Aceh Budget Transparency","chunk_id":991,"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:991]."}
event: done
data: {"finish_reason":"stop"}
Guard-blocked SSE transcript
event: message.start
data: {"conversation_id":123,"message_id":458}
event: guard.blocked
data: {"reason":"Permintaan tidak dapat dijawab karena melanggar kebijakan."}
event: done
data: {"finish_reason":"guard_blocked"}
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: [].
Request fields
| Field | Type | Required | Constraints | Meaning |
|---|---|---|---|---|
query | string | Yes | Minimum length 1 at JSON validation: "" returns 422; non-empty whitespace-only strings return 400. | Search text. |
mode | string | No | One of hybrid, semantic, lexical. Default hybrid. | Retrieval strategy. |
top_k | integer | No | Default 5; must be 1 through 50. | Maximum number of chunks returned after ranking. |
collection_ids | string array or null | No | Omitted, 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. |
Search modes
| Mode | Behavior | Use when |
|---|---|---|
hybrid | Combines semantic vector retrieval and lexical PostgreSQL full-text retrieval, then ranks the fused candidates. | Default for user-facing retrieval quality. |
semantic | Uses only semantic vector retrieval. | You need meaning-based matches without lexical fusion. |
lexical | Uses 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"]
}
Response fields
| Field | Type | Meaning |
|---|---|---|
mode | string | The mode used for the request. |
results | search result array | Ranked chunks. Empty when there are no visible matches. |
Search result object:
| Field | Type | Meaning |
|---|---|---|
chunk_id | integer | Source chunk ID. Mirrors citation.chunk_id. |
document_id | integer | Source document ID. Mirrors citation.document_id. |
ordinal | integer | Chunk ordinal within the document. |
text | string | Retrieved chunk text. |
score | number | Retrieval score for ranking. Higher-ranked results appear earlier. Do not compare scores across different modes as a stable API contract. |
citation | citation object | Full citation metadata. See Shared citation object. |
Response JSON
{
"mode": "hybrid",
"results": [
{
"chunk_id": 991,
"document_id": 42,
"ordinal": 7,
"text": "Pemerintah Aceh mempublikasikan informasi anggaran kepada masyarakat...",
"score": 0.8732,
"citation": {
"document_id": 42,
"document_title": "Qanun Aceh Budget Transparency",
"chunk_id": 991,
"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}
}
]
}
| Status | Applies to | Meaning | Body shape |
|---|---|---|---|
200 | /query | Answer returned. A guard refusal is still 200 with the refusal in answer. | QueryResponse |
200 | /query/stream | SSE stream started after setup completed. The stream terminates with done or error. | text/event-stream |
200 | /search | Search completed, including no matches. | SearchResponse with results: [] when empty |
400 | All three routes | Non-empty query string is whitespace-only after parsing. | {"detail":"Query must not be empty"} |
401 | All three routes | API 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/stream | Conversation ID is unknown or belongs to another subject; or no indexed content is available to answer. | {"detail":"..."} |
413 | /query, /query/stream | The answer context would exceed the model context window. | {"detail":"..."} |
422 | All three routes | JSON schema validation failed, such as query: "", invalid enum, conversation_id ≤ 0, top_k outside 1..50, missing query, or extra fields on /search. | {"detail":[...]} |
502 | /query, /query/stream | Model or orchestration dependency failed. | {"detail":"..."} |
502 | /search | Model 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, setup failures (400, 401, 404, 413, 422, 502) are returned before the SSE body starts. Once a 200 text/event-stream body starts, normal completion is represented by done; a late framing fault 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: number;
document_title: string;
chunk_id: number;
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?: number | null;
collection_ids?: string[] | null;
};
type QueryResponse = {
answer: string;
conversation_id: number;
message_id: number;
citations: Citation[];
};
type SearchRequest = {
query: string;
mode?: SearchMode;
top_k?: number;
collection_ids?: string[] | null;
};
type SearchResult = {
chunk_id: number;
document_id: number;
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: number; message_id: number } }
| { event: "citation"; data: Citation }
| { event: "answer.delta"; data: { text: string } }
| { event: "guard.blocked"; data: { reason: string } }
| { event: "done"; data: { finish_reason: "stop" | "guard_blocked" } }
| { event: "error"; data: { code: "stream_failed"; message: string } };
type QueryStreamCallbacks = {
onStart?: (data: { conversation_id: number; message_id: number }) => void;
onCitation?: (citation: Citation) => void;
onDelta?: (text: string) => void;
onBlocked?: (reason: string) => void;
onDone?: (finishReason: "stop" | "guard_blocked") => void;
onError?: (error: Error) => void;
signal?: AbortSignal;
};
export async function streamQuery(
options: ClientOptions,
request: QueryRequest,
callbacks: QueryStreamCallbacks,
): Promise<{ conversationId: number; messageId: number; 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: number | undefined;
let messageId: number | 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.finish_reason);
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) {
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.