Conversations and memory
These routes let clients create and list chat history, resume a conversation, 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/memory filtering when identity is verified. |
X-User-Level | Optional integer position-level claim used by access-aware retrieval/memory 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 anonymous 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": "anonymous",
"title": "Aceh budget follow-up"
}
| Field | Type | Required | Meaning |
|---|---|---|---|
user_id | string | No | Auth-off local fallback owner only. Default: anonymous. Integrated clients should omit it and rely on trusted principal headers. Must be non-empty 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": 42,
"user_id": "employee-123",
"title": "Aceh budget follow-up",
"metadata": {
"mode": "explicit-create"
}
}
| Field | Type | Meaning |
|---|---|---|
id | integer | Conversation identifier to pass as conversation_id on /query or /query/stream. |
user_id | string | 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 | No | Auth-off local fallback owner only. Default: anonymous. Ignored when trusted principal headers resolve a subject. |
Response 200
[
{
"id": 44,
"user_id": "employee-123",
"title": "Apa kewenangan Baitul Mal?",
"metadata": {
"mode": "m1-agentic"
}
},
{
"id": 42,
"user_id": "employee-123",
"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 and no server-side trimming on this read-back route.
Path and query parameters
| Parameter | Location | Type | Required | Meaning |
|---|---|---|---|---|
conversation_id | Path | integer | Yes | Conversation identifier. |
user_id | Query | string | No | Auth-off local fallback owner only. Default: anonymous. Ignored when trusted principal headers resolve a subject. |
Response 200
{
"id": 42,
"user_id": "employee-123",
"title": "Aceh budget follow-up",
"metadata": {
"mode": "explicit-create"
},
"messages": [
{
"id": 101,
"role": "user",
"content": "Apa kewenangan Baitul Mal?",
"source_citations": [],
"metadata": {}
},
{
"id": 102,
"role": "assistant",
"content": "Baitul Mal memiliki kewenangan ...",
"source_citations": [
{
"document_id": 7,
"document_title": "Qanun Aceh Nomor 10 Tahun 2018",
"chunk_id": 91,
"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
},
"retrieval": {
"semantic_candidate_limit": 50,
"lexical_candidate_limit": 50,
"rrf_k": 60,
"rerank_candidate_limit": 20,
"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",
"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. Query-created and resumed turns are persisted as 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.
A cross-subject conversation_id and an unknown conversation_id both return 404 with the same shape, for example {"detail":"Conversation 42 not found"}.
Schemas
ConversationSummary
| Field | Type | Meaning |
|---|---|---|
id | integer | Conversation id. |
user_id | string | 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 | integer | Message id. The /query response message_id is the assistant message id. |
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. |
Citation
Every citation includes these fields:
| Field | Type | Meaning |
|---|---|---|
document_id | integer | Source document id. |
document_title | string | Source document title. |
chunk_id | integer | Source chunk id. |
chunk_ordinal | integer | Chunk ordinal inside the source document. |
page_number | integer | null | Page number when known. |
section_path | string[] | Hierarchical section labels. Empty array when unavailable. |
legal_reference | string | null | Legal reference when known. |
char_start | integer | null | Start character offset when known. |
char_end | integer | null | End character offset when known. |
These fields are conditional and appear only when the source carries access scope:
| Field | Type | Meaning |
|---|---|---|
confidential | true | Present only for confidential source material. |
owning_org_unit | string | Present only when the source is scoped to an organization unit. |
min_position_level | integer | Present only when the source has a minimum position level. |
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. 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.
To resume, send a later query with the same conversation_id. The server ownership-checks the id before model work. It then threads a bounded recent-history window into planning and answer synthesis. The history window is capped by configured turn and character budgets for generation, but GET /conversations/{id} still returns all stored messages.
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 per-subject durable preferences/facts/reflections recalled by the answer path. These management routes expose only the resolved subject's own non-deleted memories.
GET /memories
Lists the resolved subject's memories. There is no pagination.
Query parameters
| Parameter | Type | Required | Meaning |
|---|---|---|---|
user_id | string | No | Auth-off local fallback owner only. Default: anonymous. Ignored when trusted principal headers resolve a subject. |
Response 200
[
{
"id": 501,
"type": "preference",
"text": "Prefers answers in Bahasa Indonesia.",
"importance": 0.72,
"status": "active",
"confidential": false,
"last_accessed_at": "2026-07-23T09:20:31.123456+00:00"
},
{
"id": 499,
"type": "fact",
"text": "Works in the Baitul Mal policy unit.",
"importance": 0.63,
"status": "active",
"confidential": true,
"last_accessed_at": null
}
]
Ordering is most-recently-updated first, with id descending as a tie-breaker. Rows with status: "deleted" are not returned. Empty memory state returns [].
DELETE /memories/{memory_id}
Deletes one owned memory.
Path and query parameters
| Parameter | Location | Type | Required | Meaning |
|---|---|---|---|---|
memory_id | Path | integer | Yes | Memory identifier. |
user_id | Query | string | No | Auth-off local fallback owner only. Default: anonymous. 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 501 not found"}.
DELETE /memories
Runs the "forget me" memory erase for the resolved subject.
Query parameters
| Parameter | Type | Required | Meaning |
|---|---|---|---|
user_id | string | No | Auth-off local fallback owner only. Default: anonymous. Ignored when trusted principal headers resolve a subject. |
Response 200
{
"removed_count": 3
}
removed_count is the number of long-term memory rows removed for the subject. If there were no memories, the response is {"removed_count":0}. This route erases long-term memories only; it does not delete conversations or message history.
MemorySummary
| Field | Type | Meaning |
|---|---|---|
id | integer | Memory id used by DELETE /memories/{memory_id}. |
type | "preference" | "fact" | "reflection" | Memory category. |
text | string | Human-readable memory text. |
importance | number | Importance weight, typically 0.0 to 1.0. |
status | "active" | "superseded" | "deleted" | Stored lifecycle status. List responses exclude deleted rows. |
confidential | boolean | Whether the memory inherited confidential source scope. |
last_accessed_at | string | null | ISO 8601 timestamp for the last recall/reinforcement, or null when never recalled. |
Status and error behavior
| Status | Where | Body |
|---|---|---|
200 | Successful GET routes and DELETE /memories | JSON response body. |
201 | POST /conversations | ConversationSummary. |
204 | 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="employee-123"
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/42" \
-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":42}'
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/501" \
-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: 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 ConversationSummary = {
id: number;
user_id: string;
title: string | null;
metadata: Record<string, unknown>;
};
type ConversationMessage = {
id: number;
role: "user" | "assistant" | "system" | "tool" | string;
content: string;
source_citations: Citation[];
metadata: Record<string, unknown>;
};
type ConversationDetail = ConversationSummary & {
messages: ConversationMessage[];
};
type MemorySummary = {
id: number;
type: "preference" | "fact" | "reflection" | string;
text: string;
importance: number;
status: "active" | "superseded" | "deleted" | string;
confidential: boolean;
last_accessed_at: string | null;
};
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: number) {
return acehApi<ConversationDetail>(`/conversations/${id}`, { method: "GET" }, subject);
}
export async function listMemories(subject: string) {
return acehApi<MemorySummary[]>("/memories", { method: "GET" }, subject);
}
export async function deleteMemory(subject: string, id: number) {
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: number): 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");
}