Chat completions
The full request and response reference for POST /v1/chat/completions.
POST /v1/chat/completionsOne call is one turn of a conversation: the member's latest message in, the
assistant's answer out. With "stream": true the answer arrives as
chunks; otherwise as one JSON completion, described
below.
Headers
| Header | Required | Meaning |
|---|---|---|
Authorization: Bearer pcai_… | Yes | Your client API key (Authentication) |
X-PlanCover-Session | No | The member session. Wins over metadata.session_id. Without either, the turn is anonymous |
X-Conversation-Id | No | The conversation id, when metadata.conversation_id is not set |
Content-Type: application/json | Yes |
The request
{
"model": "plancover-assistant",
"messages": [{ "role": "user", "content": "What is the maternity waiting period?" }],
"stream": true,
"stream_options": { "include_usage": true },
"metadata": { "conversation_id": "support-4821", "channel": "web" }
}| Field | Type | Handling |
|---|---|---|
model | string | Accepted with any value and ignored; echoed in the response's model, or plancover-assistant if empty. PlanCover chooses the model that answers |
messages | array, required | At least one message. See Messages |
stream | boolean | true streams chunks; false or absent returns one completion |
stream_options.include_usage | boolean | With stream: true, adds a usage chunk before [DONE] |
metadata | object | See Metadata |
Every other field is accepted and ignored, including temperature,
top_p, max_tokens, max_completion_tokens, stop, seed, user,
and parallel_tool_calls. tools and tool_choice are read for one thing
only: whether you declared confirm_action or sign_in, so that a paused turn
can be offered to you as a tool call.
The assistant never sees or calls a client's tools.
These fields are refused with a
400 whose param names the
field, because a client that sends them expects a response this endpoint does
not give:
| Field | Refused when |
|---|---|
n | greater than 1 |
response_format | set, and not null |
audio | set, and not null |
modalities | set, and not null, even ["text"] |
logprobs | true |
Messages
| Role | Handling |
|---|---|
user | The last one is this turn's message. Earlier ones may seed a new conversation |
assistant | Earlier answers. May seed a new conversation. content may be null |
system, developer | Dropped unread. The assistant has its own instructions |
tool | A trailing tool message answers a confirm_action or sign_in call. Earlier ones are skipped |
content is a string, or an array of parts of which only {"type": "text", "text": …} parts are read, joined with a newline.
This turn's message, the last user message, must (unless the request
answers a tool call):
- be the last message other than
systemanddeveloperones; - be text only: an image, audio or file part is a
400; - not be empty or blank;
- be at most 8000 characters.
Earlier messages are never refused for their length or their content
parts. OpenAI-style clients resend the whole transcript on every call, so a
long transcript, or an image sent earlier, must not break later turns. Each
message still needs one of the five roles above, and content must be a
string, an array of parts or null. Non-text parts are dropped from them, and an empty one is skipped. How
they are used depends on whether the conversation is new; see
Conversations.
Metadata
| Key | Type | Meaning |
|---|---|---|
conversation_id | string, 1–128 printable ASCII characters, no spaces | The conversation this turn belongs to. Wins over X-Conversation-Id. Without either, the server mints conv_<32 hex> |
session_id | string, at most 256 characters | The member session, for clients that cannot set X-PlanCover-Session. The header wins |
channel | web | mobile | whatsapp | Where the member is chatting. Default web. On whatsapp, member questions get a "sign in through the PlanCover app" answer instead of a sign-in pause |
Other keys are ignored. A conversation id that is not 1–128 printable ASCII
characters is a 400 before the turn runs, with param naming where it came
from: metadata.conversation_id or X-Conversation-Id.
The response
Without stream, the answer is one chat.completion:
{
"id": "chatcmpl-6f1d0c2a9b8e4f7d9a3c5e1b2d4f6a8c",
"object": "chat.completion",
"created": 1793260800,
"model": "plancover-assistant",
"choices": [
{
"index": 0,
"finish_reason": "stop",
"message": {
"role": "assistant",
"content": "Maternity has a nine month waiting period [1].",
"annotations": [
{
"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
}
}
]
}
}
],
"usage": { "prompt_tokens": 1841, "completion_tokens": 212, "total_tokens": 2053 },
"plancover": {
"conversation_id": "support-4821",
"message_id": "6f1d0c2a9b8e4f7d9a3c5e1b2d4f6a8c",
"finish_reason": "stop",
"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…"
}
],
"documents": []
}
}| Field | Meaning |
|---|---|
id | chatcmpl- followed by the turn's message_id |
model | The request's model, echoed |
choices | Always exactly one |
choices[0].message.content | The answer. [n] markers point at plancover.citations |
choices[0].message.annotations | One url_citation per cited source that has a URL. See Citations |
choices[0].finish_reason | stop, or tool_calls when the turn ends in a tool call. The real reason is plancover.finish_reason |
usage | Always present. See Usage |
plancover | PlanCover's extension object, described in Streaming, where the final chunk carries the same object |
Fields that are not set, such as system_fingerprint, or tool_calls when
there is no call, are left out rather than sent as null.
Response headers
| Header | Meaning |
|---|---|
X-Conversation-Id | The conversation id in use: yours, or the one the server minted |
x-request-id | The request's id, on every response. Quote it when reporting a problem |
x-should-retry: false | On a 5xx after the turn started. See Errors |
finish_reason
choices[0].finish_reason is stop, so that every OpenAI client accepts the
answer. The one exception is a turn that ends in a confirm_action or sign_in
call: that is tool_calls. See
Confirmations and sign-in. How the turn
really ended is in plancover.finish_reason:
plancover.finish_reason | Means |
|---|---|
stop | The turn ran to completion |
awaiting_sign_in | Paused for the member to sign in. plancover.auth_required is set |
awaiting_confirmation | Paused on an action that needs the member's approval. plancover.confirmation is set |
sign_in_required | A member question on a channel with no sign-in card (whatsapp) |
member_data_missing | A member question that read none of the member's records |
escalated | Handed over to a person |
insufficient_evidence | The answer cites none of the evidence it found: treat it with care |
tool_budget_exhausted, model_budget_exhausted | A step budget ran out and the turn wound down |
empty_response | The model returned no text |
cancelled | The turn was stopped at a safe point before it finished |
Treat it as an open set: a value added later should be logged, not rejected. The Native SSE API documents each value in more depth.
Usage
usage is {prompt_tokens, completion_tokens, total_tokens}, and
total_tokens is always the sum of the other two.
The figures are the totals of every model call in the turn: the step that plans the turn, the steps that gather evidence and call tools, the answer, and any fallback model that stood in for one of them. Each call counts once. The reranker that orders search results is not a language-model call and is not counted. A turn in which no model ran reports zeros.
A turn that pauses counts the calls made before the pause. The request that answers it counts the calls made after.
usage has no cost. PlanCover records each turn's tokens, and the cost its
model provider reports, against the key that made the call.
Non-streaming calls and retries
A non-streaming call sends nothing until the turn ends: up to
TURN_TIMEOUT_SECONDS (120 seconds by default), plus about 10 seconds to
wind down. Set your client's timeout to at least 150 seconds and turn its
automatic retries off, or a timeout replays the turn.
See Retries and timeouts.