Skip to main content

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:

HeaderMeaning
X-User-SubjectStable subject that owns conversations and memories.
X-User-RoleRole claim; defaults to user when omitted after a subject is supplied.
X-User-UnitOptional organization-unit claim used by access-aware retrieval/memory filtering when identity is verified.
X-User-LevelOptional 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"
}
FieldTypeRequiredMeaning
user_idstringNoAuth-off local fallback owner only. Default: anonymous. Integrated clients should omit it and rely on trusted principal headers. Must be non-empty when supplied.
titlestring | nullNoOptional 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"
}
}
FieldTypeMeaning
idintegerConversation identifier to pass as conversation_id on /query or /query/stream.
user_idstringResolved owner subject. This is the trusted principal subject when one is present.
titlestring | nullStored title.
metadataobjectFor explicit creates, currently {"mode":"explicit-create"}.

GET /conversations

Lists the resolved subject's conversations. There is no pagination.

Query parameters

ParameterTypeRequiredMeaning
user_idstringNoAuth-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

ParameterLocationTypeRequiredMeaning
conversation_idPathintegerYesConversation identifier.
user_idQuerystringNoAuth-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

FieldTypeMeaning
idintegerConversation id.
user_idstringOwner subject.
titlestring | nullDisplay title. Query-created conversations use the first 80 characters of the query as the title.
metadataobjectCreation/source metadata. Explicit creates currently use mode: "explicit-create"; query-created conversations currently use mode: "m1-agentic".

ConversationDetail

ConversationDetail has all ConversationSummary fields plus:

FieldTypeMeaning
messagesConversationMessage[]All stored messages for the conversation, oldest-first.

ConversationMessage

FieldTypeMeaning
idintegerMessage id. The /query response message_id is the assistant message id.
rolestringStored message role, normally user or assistant.
contentstringOriginal user text or guarded assistant answer text.
source_citationsCitation[]Empty for user messages. For assistant messages, this reuses the same citation objects returned by /query and emitted as SSE citation events.
metadataobject{} for user messages. Assistant messages include conversation, retrieval, synthesis, and policy-guard metadata as shown above.

Citation

Every citation includes these fields:

FieldTypeMeaning
document_idintegerSource document id.
document_titlestringSource document title.
chunk_idintegerSource chunk id.
chunk_ordinalintegerChunk ordinal inside the source document.
page_numberinteger | nullPage number when known.
section_pathstring[]Hierarchical section labels. Empty array when unavailable.
legal_referencestring | nullLegal reference when known.
char_startinteger | nullStart character offset when known.
char_endinteger | nullEnd character offset when known.

These fields are conditional and appear only when the source carries access scope:

FieldTypeMeaning
confidentialtruePresent only for confidential source material.
owning_org_unitstringPresent only when the source is scoped to an organization unit.
min_position_levelintegerPresent only when the source has a minimum position level.

Resume workflow

There are two ways to obtain a conversation id:

  1. Explicit conversation: call POST /conversations, then pass the returned id as conversation_id in /query or /query/stream.
  2. Query-created conversation: omit conversation_id on the first /query or /query/stream call. 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 new conversation_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

ParameterTypeRequiredMeaning
user_idstringNoAuth-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

ParameterLocationTypeRequiredMeaning
memory_idPathintegerYesMemory identifier.
user_idQuerystringNoAuth-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

ParameterTypeRequiredMeaning
user_idstringNoAuth-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

FieldTypeMeaning
idintegerMemory id used by DELETE /memories/{memory_id}.
type"preference" | "fact" | "reflection"Memory category.
textstringHuman-readable memory text.
importancenumberImportance weight, typically 0.0 to 1.0.
status"active" | "superseded" | "deleted"Stored lifecycle status. List responses exclude deleted rows.
confidentialbooleanWhether the memory inherited confidential source scope.
last_accessed_atstring | nullISO 8601 timestamp for the last recall/reinforcement, or null when never recalled.

Status and error behavior

StatusWhereBody
200Successful GET routes and DELETE /memoriesJSON response body.
201POST /conversationsConversationSummary.
204DELETE /memories/{memory_id}Empty body.
401Missing/invalid API key, or missing/invalid principal when principal enforcement is enabled{"detail":"..."}.
404Unknown or cross-subject conversation/memory id{"detail":"Conversation {id} not found"} or {"detail":"Memory {id} not found"}.
422FastAPI validation failure, including wrong JSON field types or extra fields on POST /conversationsFastAPI 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");
}