Conversations and memory
These routes let clients create, list, resume, and delete chat history; list and delete long-term memories; and run a "forget me" memory erase. They are root-mounted application routes; use /openapi.json in the target environment for machine-readable discovery. Swagger UI is available at /docs outside production.
There is no WebSocket API for these flows. Resume answering uses the JSON or SSE query routes documented in Query and search; conversation and memory management use the HTTP routes below.
Authentication and identity
All routes require the configured API-key header. The default header name is X-API-Key.
Principal-bearing routes also bind ownership to trusted principal headers supplied by the edge or backend-for-frontend layer:
| Header | Meaning |
|---|---|
X-User-Subject | Stable subject that owns conversations and memories. |
X-User-Role | Role claim; defaults to user when omitted after a subject is supplied. |
X-User-Unit | Optional organization-unit claim used by access-aware retrieval filtering when identity is verified. |
X-User-Level | Optional integer position-level claim used by access-aware retrieval filtering when identity is verified. |
user_id request and query parameters are only a local fallback for development/back-compat. Integrated clients must not rely on auth-off fallback mode; they should use trusted principal headers injected by the edge, BFF, or gateway with principal enforcement enabled. Browser code must not embed a shared API key and must not let end users set or forge X-User-* headers.
When principal enforcement is enabled, missing identity returns 401 with {"detail":"Authenticated principal required"} and malformed identity returns 401 with {"detail":"Invalid authenticated principal"}. When enforcement is disabled, missing or malformed identity claims are discarded: the route user_id fallback becomes the owner when supplied, otherwise the all-zero sentinel UUID becomes the owner.
Conversation history
POST /conversations
Creates an explicit, initially empty conversation owned by the resolved subject. Use this when the UI has a "new chat" action before the first question. If the first user action is a query, the query route can create the conversation automatically instead.
Request body
{
"user_id": "00000000-0000-0000-0000-000000000001",
"title": "Aceh budget follow-up"
}
| Field | Type | Required | Meaning |
|---|---|---|---|
user_id | string (UUID) | No | Auth-off local fallback owner only. Default: the all-zero sentinel UUID. Integrated clients should omit it and rely on trusted principal headers. Must be a valid UUID when supplied. |
title | string | null | No | Optional display title. Must be non-empty when supplied. |
No request metadata field is accepted. Extra fields are rejected with FastAPI 422 validation errors.
Response 201
{
"id": "00000000-0000-0000-0000-000000000042",
"user_id": "00000000-0000-0000-0000-000000000001",
"title": "Aceh budget follow-up",
"metadata": {
"mode": "explicit-create"
}
}
| Field | Type | Meaning |
|---|---|---|
id | string (UUID) | Conversation identifier to pass as conversation_id on /query or /query/stream. |
user_id | string (UUID) | Resolved owner subject. This is the trusted principal subject when one is present. |
title | string | null | Stored title. |
metadata | object | For explicit creates, currently {"mode":"explicit-create"}. |
GET /conversations
Lists the resolved subject's conversations. There is no pagination.
Query parameters
| Parameter | Type | Required | Meaning |
|---|---|---|---|
user_id | string (UUID) | No | Auth-off local fallback owner only. Default: the all-zero sentinel UUID. Ignored when trusted principal headers resolve a subject. |
Response 200
[
{
"id": "00000000-0000-0000-0000-000000000044",
"user_id": "00000000-0000-0000-0000-000000000001",
"title": "Apa kewenangan Baitul Mal?",
"metadata": {
"mode": "m1-agentic"
}
},
{
"id": "00000000-0000-0000-0000-000000000042",
"user_id": "00000000-0000-0000-0000-000000000001",
"title": "Aceh budget follow-up",
"metadata": {
"mode": "explicit-create"
}
}
]
Ordering is most-recently-created first, with a deterministic id tie-breaker. Empty history returns [].
GET /conversations/{conversation_id}
Returns one owned conversation and all stored messages. There is no pagination or message trimming; evidence metadata is filtered and redacted as described below.
Path and query parameters
| Parameter | Location | Type | Required | Meaning |
|---|---|---|---|---|
conversation_id | Path | string (UUID) | Yes | Conversation identifier. |
user_id | Query | string (UUID) | No | Auth-off local fallback owner only. Default: the all-zero sentinel UUID. Ignored when trusted principal headers resolve a subject. |
Response 200
{
"id": "00000000-0000-0000-0000-000000000042",
"user_id": "00000000-0000-0000-0000-000000000001",
"title": "Aceh budget follow-up",
"metadata": {
"mode": "explicit-create"
},
"messages": [
{
"id": "00000000-0000-0000-0000-000000000101",
"trace_id": null,
"role": "user",
"content": "Apa kewenangan Baitul Mal?",
"source_citations": [],
"metadata": {}
},
{
"id": "00000000-0000-0000-0000-000000000102",
"trace_id": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
"role": "assistant",
"content": "Baitul Mal memiliki kewenangan ...",
"source_citations": [
{
"document_id": "00000000-0000-0000-0000-000000000007",
"document_title": "Qanun Aceh Nomor 10 Tahun 2018",
"chunk_id": "00000000-0000-0000-0000-000000000091",
"chunk_ordinal": 12,
"page_number": 4,
"section_path": ["Bab II", "Pasal 5"],
"legal_reference": "Pasal 5",
"char_start": 1200,
"char_end": 1538
}
],
"metadata": {
"conversation": {
"resumed": true,
"history_turns_threaded": 2,
"history_truncated": false,
"history_exchanges_in_view": 1
},
"retrieval": {
"semantic_candidate_limit": 50,
"lexical_candidate_limit": 50,
"rrf_k": 60,
"rerank_candidate_limit": 60,
"final_top_k": 5,
"reranker_enabled": true
},
"synthesis": {
"mode": "m1-agentic",
"note": "M1 agentic Synthesizer stage; retrieval ran via a tool dispatched by the Executor (ADR-0023 / ADR-0024).",
"tool": "vector_search",
"tool_executed": "vector_search",
"directed_synthesis": false,
"no_answer": false,
"faithfulness": {
"mechanism": "deterministic M0 citation-support heuristic",
"action_on_failure": "drop unsupported claim; use cited fallback when no claim survives",
"dropped_claims": [],
"fallback_used": false
}
},
"policy_guard": {
"mechanism": "deterministic-rules-v2",
"action": "allowed",
"categories": []
}
}
}
]
}
Messages are oldest-first. Persisted query-created and resumed turns contain a user message followed by an assistant message. In normal query traffic, role is user or assistant; the wire schema can also carry stored system or tool roles if present.
Assistant metadata.synthesis.used_docs retains citation identities but omits every
record's text. Chunk records remain only when the current reader can access the
chunk; workspace access to a conversation does not grant access to its corpus.
Non-chunk records remain only for native OpenData/BPS tools with HTTPS source URLs
on data.acehprov.go.id, satudata.acehprov.go.id, aceh.bps.go.id, or
webapi.bps.go.id (default HTTPS port, no URL credentials). Spreadsheet,
corpus_facts, and MCP receipts are omitted because this read cannot reauthorize
them. Pre-guard dropped_claims and unparsed_completion are also withheld;
faithfulness counts and reasons remain available. Passage text for a streamed
answer arrives in the guarded SSE passage events,
not in conversation read-back.
A cross-subject conversation_id and an unknown conversation_id both return 404 with the same shape, for example {"detail":"Conversation 00000000-0000-0000-0000-000000000042 not found"}.
DELETE /conversations/{conversation_id}
Deletes one conversation owned by the resolved subject. Every message in that conversation is deleted in the same database operation through the messages foreign-key cascade.
The path and optional user_id query parameter have the same ownership semantics as GET /conversations/{conversation_id}. Success returns 204 No Content with an empty body. After success, the conversation is absent from GET /conversations, and GET /conversations/{conversation_id} returns 404.
Unknown and cross-subject conversation IDs both return the same 404 response. A cross-subject attempt does not delete the conversation or any of its messages.
Schemas
ConversationSummary
| Field | Type | Meaning |
|---|---|---|
id | string (UUID) | Conversation id. |
user_id | string (UUID) | Owner subject. |
title | string | null | Display title. Query-created conversations use the first 80 characters of the query as the title. |
metadata | object | Creation/source metadata. Explicit creates currently use mode: "explicit-create"; query-created conversations currently use mode: "m1-agentic". |
ConversationDetail
ConversationDetail has all ConversationSummary fields plus:
| Field | Type | Meaning |
|---|---|---|
messages | ConversationMessage[] | All stored messages for the conversation, oldest-first. |
ConversationMessage
| Field | Type | Meaning |
|---|---|---|
id | string (UUID) | Message id. The /query response message_id is the assistant message id. |
trace_id | string | null | Opaque Langfuse trace ID for an assistant answer. User messages and untraced answers use null. |
role | string | Stored message role, normally user or assistant. |
content | string | Original user text or guarded assistant answer text. |
source_citations | Citation[] | Empty for user messages. For assistant messages, this reuses the same citation objects returned by /query and emitted as SSE citation events. |
metadata | object | {} for user messages. Assistant messages include conversation, retrieval, synthesis, and policy-guard metadata as shown above. |
PUT /messages/{message_id}/feedback
Records the caller's like/dislike on an assistant answer to a question they asked, with an
optional reason and note. Only the person who asked may rate the answer: the author_id of
the user message it answers, or the conversation's owner when that message records no
author (older rows, chats outside a workspace). An answer has one feedback slot, so in a
shared workspace chat another member, whatever their role, would otherwise replace the
asker's complaint; for them the message is 404, as if it did not exist. The feedback is stored on the message and written to the audit trail
first; the rating is then scored on the answer's Langfuse trace, which the server resolves
from storage (callers cannot select an arbitrary trace).
| Field | Type | Notes |
|---|---|---|
rating | "like" | "dislike" | Required. A rating alone is a complete request. |
reason | string | null | Optional: wrong_answer, wrong_citation, incomplete, outdated, offensive_or_sara or other. Any other value is 422. |
note | string | null | Optional free text, at most 1,000 characters (422 above). Read by reviewers only; never sent to Langfuse or copied into the audit trail. |
{"rating":"dislike","reason":"wrong_citation","note":"Pasal yang dikutip bukan Pasal 7."}
A successful response is:
{"message_id":"00000000-0000-0000-0000-000000000102","rating":"dislike","linked":true}
linked is false when the feedback is stored but did not reach Langfuse: the answer has
no trace, or Langfuse was unavailable. That is still a 200; do not retry it or show an
error. Suggested Bahasa Indonesia labels for the reasons: Jawaban salah, Sitasi salah,
Tidak lengkap, Kedaluwarsa, SARA/menyinggung, Lainnya.
The operation is idempotent: a repeated PUT stores the same values and scores the same
deterministic Langfuse score ID, and a later PUT replaces the earlier rating, reason and
note. A dislike opens the answer for admin review, and reopens it after an admin
resolved it; it does not reset a review in progress. A later like does not close it.
Unknown and non-assistant messages, and answers to a question the caller did not ask,
return 404. Only the rating and opaque
message/trace IDs are sent to Langfuse—never answer text, the note, source passages, or
credentials.
:::note Contract change (3 Nov 2026)
reason and note are new and optional, so existing rating-only calls are unchanged.
linked is now a boolean instead of always true, and the 409 (untraced answer) and
503 (Langfuse unavailable) responses are gone: both now return 200 with
linked: false, and the feedback is kept. In a workspace chat, only the member who asked
the question may rate its answer; any other member now gets 404.
:::
Citation
See the shared citation contract for document and live-read fields, conditional access scope, conversation provenance, and the compatibility rule for older stored messages.
Resume workflow
There are two ways to obtain a conversation id:
- Explicit conversation: call
POST /conversations, then pass the returnedidasconversation_idin/queryor/query/stream. - Query-created conversation: omit
conversation_idon the first/queryor/query/streamcall. When persistence completes, the query route creates a new conversation, titles it from the first 80 characters of the query, persists the user and assistant messages, and returns the newconversation_id.
Timeout persistence exceptions and ID handling follow ADR-0041.
To resume, send the same conversation_id. The server checks ownership before model work. The recent-history limit counts messages, not user/assistant pairs; each message and the combined recent window have configured character bounds. Ordinary recall and mixed chat questions in a caller-owned, unshared chat also receive the separately bounded first exchange. The whole generation prompt still fits the served context window, trimming other history explicitly before that first exchange when needed. GET /conversations/{id} continues to expose the stored transcript.
For the history-window report and notice contract on JSON answers and SSE completion, including pre-plan refusals and unknown windows, see Query response fields.
New assistant messages persist history_truncated and, when known,
history_exchanges_in_view under metadata.conversation, so a client can show
the same notice when reopening a chat. Older stored messages have no window
report; clients must not infer a notice from transcript length. This is a
stopgap, not a memory note: omitted exchanges are not summarized.
A strict self-hosted intent judge separates conversation-only recall from corpus questions, subject to the upload-state recall override. Pure recall has no document, portal, or MCP tools. An independent self-hosted judge sees only a claim and bounded user records, and must return a supported claim with a known message ID. It does not see assistant answers or the generator context; invalid or unavailable judgments fail closed. Prior assistant speech may be quoted as speech, never used to establish corpus facts.
Memory enhancements apply only to ordinary chat answers in caller-owned, unshared chats. Exports and shared chats retain the existing bounded recent-history behavior; neither receives newly retained first exchanges or reused passage packets. Export requests use the existing artifact checks, not new recall support. Unknown speakers cannot support personal recall.
For independent support checks and the retained REG-256/REG-264 USER-history exception on legacy corpus paths, see the faithfulness gate reference.
For reuse of cited document passages, persisted prefix bounds, and supplemental search, see Document follow-ups.
The assistant message's source_citations are the durable copy of the answer citations. A UI can render citations from the immediate /query response, then later rebuild the same citation display from GET /conversations/{id} without re-querying.
Long-term memory
Long-term memories are short, per-subject notes the system keeps about a user across conversations: their preferences (answer language, length, format) and their work context (the regulations or topics they keep returning to). A conversation is distilled into at most three memories once it has been idle for 30 minutes; every later question carries that user's most relevant memories. Memories are never about documents, and an answer never cites one as a source. The full write/read design is in Long-Term Memory.
These routes work whether or not memory is switched on, so a user can always see and erase what is held about them. They expose only the resolved subject's own active memories. On a deployment that sets ACEH_RAG_MEMORY__SUBJECT_ALLOWLIST, a subject outside that list has no memories there: GET returns [], DELETE /memories returns {"removed_count":0} and DELETE /memories/{memory_id} returns 404.
GET /memories
Lists the resolved subject's active memories, newest first. There is no pagination; a subject holds at most ACEH_RAG_MEMORY__MAX_ACTIVE_PER_USER (default 30).
Query parameters
| Parameter | Type | Required | Meaning |
|---|---|---|---|
user_id | string (UUID) | No | Auth-off local fallback owner only. Default: the all-zero sentinel UUID. Ignored when trusted principal headers resolve a subject. |
Response 200
[
{
"id": "00000000-0000-0000-0000-000000000501",
"type": "preference",
"text": "Pengguna lebih suka jawaban singkat dalam Bahasa Indonesia.",
"importance": 0.7,
"status": "active",
"source_conversation_id": "00000000-0000-0000-0000-000000000301",
"created_at": "2026-10-16T09:20:31.123456+00:00"
}
]
Empty memory state returns [].
DELETE /memories/{memory_id}
Erases one owned memory permanently.
Path and query parameters
| Parameter | Location | Type | Required | Meaning |
|---|---|---|---|---|
memory_id | Path | string (UUID) | Yes | Memory identifier. |
user_id | Query | string (UUID) | No | Auth-off local fallback owner only. Default: the all-zero sentinel UUID. Ignored when trusted principal headers resolve a subject. |
Response 204
A successful delete returns 204 No Content with an empty body. Do not call response.json() for this response; treat the status code as the result.
An unknown memory_id and a cross-subject memory_id both return 404, for example {"detail":"Memory 00000000-0000-0000-0000-000000000501 not found"}.
DELETE /memories
Runs the "forget me" erase for the resolved subject: every memory row, including ones an update replaced, is deleted permanently.
Query parameters
| Parameter | Type | Required | Meaning |
|---|---|---|---|
user_id | string (UUID) | No | Auth-off local fallback owner only. Default: the all-zero sentinel UUID. Ignored when trusted principal headers resolve a subject. |
Response 200
{
"removed_count": 3
}
removed_count is the number of memory rows removed. If there were none, the response is {"removed_count":0}. This route erases long-term memories only; deleting a conversation does not erase the memories written from it, and this route does not delete conversations.
MemorySummary
| Field | Type | Meaning |
|---|---|---|
id | string (UUID) | Memory id used by DELETE /memories/{memory_id}. |
type | "preference" | "fact" | A preference (language, length, format) or a fact the user shared about themselves or their work. |
text | string | One sentence written by the model from the user's own earlier conversations, checked by the Government Policy Guard before it was stored. Escape it on render. |
importance | number | 0.0 to 1.0; one of the recall ranking weights. |
status | "active" | Only active memories are listed. |
source_conversation_id | string | null | The conversation the memory was written from. |
created_at | string | ISO 8601 creation time. |
Memory in answers
When memory is on, a /query answer that restates a memory (for example, "what do you remember about me?") carries a claim-map entry with {"kind": "memory", "memory_id": "<id>", "chunk_id": null, "document_id": null}. Like a history citation it is never added to citations or source_citations, so a document list never shows a memory. provenance_complete=true with citations=[] is a real combination for a pure memory answer.
Status and error behavior
| Status | Where | Body |
|---|---|---|
200 | Successful GET routes and DELETE /memories | JSON response body. |
201 | POST /conversations | ConversationSummary. |
204 | DELETE /conversations/{conversation_id} and DELETE /memories/{memory_id} | Empty body. |
401 | Missing/invalid API key, or missing/invalid principal when principal enforcement is enabled | {"detail":"..."}. |
404 | Unknown or cross-subject conversation/memory id | {"detail":"Conversation {id} not found"} or {"detail":"Memory {id} not found"}. |
422 | FastAPI validation failure, including wrong JSON field types or extra fields on POST /conversations | FastAPI validation detail array. |
The application does not add app-level CORS headers. Browser integrations should call a same-origin BFF or a gateway-managed endpoint.
curl examples
Set environment variables once:
BASE_URL="https://api.example.gov"
API_KEY="replace-with-real-key" # pragma: allowlist secret
SUBJECT="00000000-0000-0000-0000-000000000001"
Create an explicit conversation:
curl -sS -X POST "$BASE_URL/conversations" \
-H "X-API-Key: $API_KEY" \
-H "X-User-Subject: $SUBJECT" \
-H "X-User-Role: user" \
-H "Content-Type: application/json" \
-d '{"title":"Aceh budget follow-up"}'
List conversations:
curl -sS "$BASE_URL/conversations" \
-H "X-API-Key: $API_KEY" \
-H "X-User-Subject: $SUBJECT"
Fetch a conversation for resume/history rendering:
curl -sS "$BASE_URL/conversations/00000000-0000-0000-0000-000000000042" \
-H "X-API-Key: $API_KEY" \
-H "X-User-Subject: $SUBJECT"
Delete a conversation and all of its messages:
curl -sS -X DELETE "$BASE_URL/conversations/00000000-0000-0000-0000-000000000042" \
-H "X-API-Key: $API_KEY" \
-H "X-User-Subject: $SUBJECT"
Resume with JSON query:
curl -sS -X POST "$BASE_URL/query" \
-H "X-API-Key: $API_KEY" \
-H "X-User-Subject: $SUBJECT" \
-H "Content-Type: application/json" \
-d '{"query":"Ringkas jawaban sebelumnya dalam 3 poin.","conversation_id":"00000000-0000-0000-0000-000000000042"}'
List memories:
curl -sS "$BASE_URL/memories" \
-H "X-API-Key: $API_KEY" \
-H "X-User-Subject: $SUBJECT"
Delete one memory and handle 204:
curl -i -X DELETE "$BASE_URL/memories/00000000-0000-0000-0000-000000000501" \
-H "X-API-Key: $API_KEY" \
-H "X-User-Subject: $SUBJECT"
Forget all long-term memories for the subject:
curl -sS -X DELETE "$BASE_URL/memories" \
-H "X-API-Key: $API_KEY" \
-H "X-User-Subject: $SUBJECT"
TypeScript examples
Use the shared API key only on a trusted server/BFF. The browser calls same-origin routes that derive the subject from the authenticated web session.
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 ConversationSummary = {
id: string;
user_id: string;
title: string | null;
metadata: Record<string, unknown>;
};
type ConversationMessage = {
id: string;
trace_id: string | null;
role: "user" | "assistant" | "system" | "tool" | string;
content: string;
source_citations: Citation[];
metadata: Record<string, unknown>;
};
type ConversationDetail = ConversationSummary & {
messages: ConversationMessage[];
};
type MemorySummary = {
id: string;
type: "preference" | "fact";
text: string;
importance: number;
status: "active";
source_conversation_id: string | null;
created_at: string;
};
async function acehApi<T>(path: string, init: RequestInit, subject: string): Promise<T> {
const response = await fetch(`${process.env.ACEH_API_BASE_URL}${path}`, {
...init,
headers: {
"X-API-Key": process.env.ACEH_API_KEY!,
"X-User-Subject": subject,
"Content-Type": "application/json",
...(init.headers ?? {}),
},
});
if (response.status === 204) {
return undefined as T;
}
const body = await response.json();
if (!response.ok) {
const message = typeof body?.detail === "string" ? body.detail : "API request failed";
throw new Error(message);
}
return body as T;
}
export async function createConversation(subject: string, title?: string) {
return acehApi<ConversationSummary>(
"/conversations",
{ method: "POST", body: JSON.stringify({ title: title ?? null }) },
subject,
);
}
export async function listConversations(subject: string) {
return acehApi<ConversationSummary[]>("/conversations", { method: "GET" }, subject);
}
export async function getConversation(subject: string, id: string) {
return acehApi<ConversationDetail>(`/conversations/${id}`, { method: "GET" }, subject);
}
export async function deleteConversation(subject: string, id: string) {
await acehApi<void>(`/conversations/${id}`, { method: "DELETE" }, subject);
}
export async function listMemories(subject: string) {
return acehApi<MemorySummary[]>("/memories", { method: "GET" }, subject);
}
export async function deleteMemory(subject: string, id: string) {
await acehApi<void>(`/memories/${id}`, { method: "DELETE" }, subject);
}
export async function forgetMe(subject: string) {
return acehApi<{ removed_count: number }>("/memories", { method: "DELETE" }, subject);
}
A browser component should call your own same-origin BFF endpoints, not the upstream API directly:
async function browserListHistory(): Promise<ConversationSummary[]> {
const response = await fetch("/bff/aceh/conversations", { credentials: "include" });
if (!response.ok) throw new Error("Could not load conversation history");
return response.json();
}
async function browserDeleteMemory(id: string): Promise<void> {
const response = await fetch(`/bff/aceh/memories/${id}`, {
method: "DELETE",
credentials: "include",
});
if (response.status === 204) return;
const body = await response.json().catch(() => null);
throw new Error(body?.detail ?? "Could not delete memory");
}