Citations and documents
The sources an answer rests on, OpenAI url_citation annotations, and downloading a member's documents by handle.
An answer can point at two kinds of material:
- citations: passages of PlanCover's policy knowledge base that the answer
rests on, marked
[1],[2]… in the text; - documents: the member's own files, such as an e-card or a policy document, offered for download through a short-lived handle.
Both arrive in the plancover object:
on the final chunk of a stream, or on a non-streaming completion.
Citations
{
"index": 1,
"id": "chunk-gmc-14",
"title": "Group Medical Cover — Maternity — p.12",
"document_id": "gmc-2026",
"url": "https://docs.plancover.example/kb/gmc-2026",
"snippet": "Maternity benefits are payable after a waiting period of nine months…"
}| Field | Type | Meaning |
|---|---|---|
index | integer | The n of the [n] marker in the answer |
id | string | The passage's id |
title | string | A label for the passage: document, section, page |
document_id | string | The knowledge-base document the passage comes from |
url | string or null | A link to that document, when the deployment has one configured |
snippet | string or null | A short excerpt of the passage |
Every key is always present. Only sources the answer actually used are
listed: a citation that does not match evidence found for this turn is dropped
rather than sent. An answer with no sources has "citations": [].
To render them, replace each [n] in the text with a link or a chip for the
citation whose index is n. On a stream, citations arrive only on the final
chunk, after the text.
Annotations
A non-streaming completion also carries OpenAI-style annotations on
choices[0].message.annotations, so clients that already render OpenAI
citations work unchanged:
{
"type": "url_citation",
"url_citation": {
"url": "https://docs.plancover.example/kb/gmc-2026",
"title": "Group Medical Cover — Maternity — p.12",
"start_index": 42,
"end_index": 45
}
}- One annotation per citation that has a URL and whose
[n]marker is in the text. A citation without a URL gets none, because a partial annotation makes@ai-sdk/openaireject the whole response. start_indexandend_indexspan the marker's first occurrence, socontent[start_index:end_index]is"[1]".annotationsis[]when no citation qualifies.- Streams carry no annotations. Use
plancover.citations.
plancover.citations is the complete list. Annotations are a convenience
subset of it.
Document handles
When the member asks for their e-card or policy documents, the answer names
them, and plancover.documents lists them:
{ "documents": [{ "name": "E-card", "handle": "6741414141414141…" }] }| Field | Meaning |
|---|---|
name | The document's name, to show the member |
handle | An opaque, encrypted reference to the file. It is not a URL |
Documents come only from a signed-in member's own data, so a turn without a
signed-in session always has "documents": [].
Downloading a document
Your backend fetches the file with your key and the same member session the turn ran with, and passes the bytes on to the member:
curl "$BASE_URL/documents/$HANDLE" \
-H "Authorization: Bearer $CHAT_API_KEY" \
-H "X-PlanCover-Session: $SESSION" \
-o ecard.pdfThe answer is the file itself, streamed, with its own Content-Type,
Content-Disposition: attachment and Cache-Control: private, no-store. It is
never a redirect: the file's address at PlanCover is never exposed.
| Status | code | When |
|---|---|---|
401 | unauthorised | No session, or a malformed one |
403 | forbidden | The handle has expired, was minted for another member, or is not a handle. All three get the same answer |
503 | upstream_unavailable | The file could not be fetched from PlanCover right now, or declares itself too large to download |
This route is not under /v1, so its errors use the
native envelope.
Handles expire after five minutes
A handle works for DOCUMENT_HANDLE_TTL_SECONDS (300 seconds by default)
from when the assistant fetched it, and only for the member it was minted for.
Fetch it when the member clicks, or straight away; to get a fresh one, ask
the assistant again. Never put a handle in a page URL or a log: proxy the
download through your backend.