Quickstart
Call the assistant from curl, the OpenAI SDKs, the Vercel AI SDK, AgentKit or LangChain.
POST /v1/chat/completions speaks OpenAI's Chat Completions protocol, so any
OpenAI-style client can call the assistant: point its base URL at
<base URL>/v1 and give it your API key. Chat Completions is the only OpenAI
endpoint served; the Responses API and /v1/models answer
404 unsupported_endpoint.
Before you start
You need:
- a client API key for the environment,
pcai_live_…in prod andpcai_test_…elsewhere (Authentication); - the environment's base URL (see the overview);
- a member session, if the conversation should carry on from call to call or reach the member's own data. Mint one once per member and keep it:
export BASE_URL=https://chat.142.93.215.84.sslip.io # staging: https://chat-staging.142.93.215.84.sslip.io
export CHAT_API_KEY=pcai_live_... # staging: pcai_test_...
export SESSION=$(curl -s -X POST "$BASE_URL/auth/session" \
-H "Authorization: Bearer $CHAT_API_KEY" | jq -r .session)- a conversation id that you choose and send on every call of one conversation. Without one, every call starts a new conversation (Conversations).
All the examples below run on your server. The key never goes to a browser or an app.
curl
curl -N "$BASE_URL/v1/chat/completions" \
-H "Authorization: Bearer $CHAT_API_KEY" \
-H "X-PlanCover-Session: $SESSION" \
-H "Content-Type: application/json" \
-d '{
"model": "plancover-assistant",
"messages": [{ "role": "user", "content": "What is the maternity waiting period?" }],
"stream": true,
"metadata": { "conversation_id": "support-4821" }
}'Drop "stream": true to get one JSON
completion instead of
chunks.
OpenAI Python
import os
from openai import OpenAI
client = OpenAI(
base_url=f"{os.environ['BASE_URL']}/v1",
api_key=os.environ["CHAT_API_KEY"],
default_headers={"X-PlanCover-Session": os.environ["SESSION"]},
)
stream = client.chat.completions.create(
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"},
)
for chunk in stream:
if chunk.choices:
print(chunk.choices[0].delta.content or "", end="", flush=True)
if plancover := (chunk.model_extra or {}).get("plancover"):
print("\n", plancover["finish_reason"], plancover["citations"])Fields outside OpenAI's schema, like plancover and the reasoning_content
status lines, are on model_extra.
OpenAI JavaScript
import OpenAI from "openai";
const client = new OpenAI({
baseURL: `${process.env.BASE_URL}/v1`,
apiKey: process.env.CHAT_API_KEY,
defaultHeaders: { "X-PlanCover-Session": process.env.SESSION },
});
const stream = await client.chat.completions.create({
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" },
});
for await (const chunk of stream) {
process.stdout.write(chunk.choices[0]?.delta?.content ?? "");
}Vercel AI SDK
Use either provider. Both call /chat/completions.
@ai-sdk/openai-compatible
import { createOpenAICompatible } from "@ai-sdk/openai-compatible";
import { streamText } from "ai";
const plancover = createOpenAICompatible({
name: "plancover",
baseURL: `${process.env.BASE_URL}/v1`,
apiKey: process.env.CHAT_API_KEY,
headers: { "X-PlanCover-Session": process.env.SESSION },
includeUsage: true,
});
const result = streamText({
model: plancover("plancover-assistant"),
prompt: "What is the maternity waiting period?",
providerOptions: { plancover: { metadata: { conversation_id: "support-4821" } } },
});
for await (const text of result.textStream) process.stdout.write(text);
console.log(await result.finishReason); // "stop"This provider surfaces the tool status lines as reasoning
(result.reasoningText, or reasoning-delta parts).
@ai-sdk/openai with openai.chat()
import { createOpenAI } from "@ai-sdk/openai";
import { streamText } from "ai";
const openai = createOpenAI({
baseURL: `${process.env.BASE_URL}/v1`,
apiKey: process.env.CHAT_API_KEY,
headers: { "X-PlanCover-Session": process.env.SESSION },
});
const result = streamText({
model: openai.chat("plancover-assistant"),
prompt: "What is the maternity waiting period?",
providerOptions: { openai: { metadata: { conversation_id: "support-4821" } } },
});
for await (const text of result.textStream) process.stdout.write(text);openai.chat(), not openai()
openai("plancover-assistant") calls the Responses API, which answers
404 unsupported_endpoint. Use openai.chat(…).
To read the plancover object
with either provider, pass includeRawChunks: true to streamText and take it
from the raw part whose rawValue.plancover is set.
AgentKit
AgentKit cannot set headers, so the session and the conversation id ride in
metadata. It does not stream.
import { createAgent, openai } from "@inngest/agent-kit";
const assistant = createAgent({
name: "plancover-assistant",
system: "Answer health-insurance questions.", // sent, and ignored by the server
model: openai({
model: "plancover-assistant",
baseUrl: `${process.env.BASE_URL}/v1`,
apiKey: process.env.CHAT_API_KEY,
defaultParameters: {
metadata: { conversation_id: "support-4821", session_id: process.env.SESSION },
},
}),
});
const { output } = await assistant.run("What is the maternity waiting period?");Compliance: AgentKit inside Inngest functions
Inside an Inngest function, step.ai.infer sends the request through
Inngest's cloud, so member messages and the session id leave PlanCover's
infrastructure. That is fine for knowledge-only bots with no session. A bot
that acts for members this way needs PlanCover's sign-off first.
LangChain
import os
from langchain_openai import ChatOpenAI
llm = ChatOpenAI(
model="plancover-assistant",
base_url=f"{os.environ['BASE_URL']}/v1",
api_key=os.environ["CHAT_API_KEY"],
default_headers={"X-PlanCover-Session": os.environ["SESSION"]},
extra_body={"metadata": {"conversation_id": "support-4821"}},
)
for part in llm.stream("What is the maternity waiting period?"):
print(part.content, end="", flush=True)extra_body, not model_kwargs
model_kwargs={"metadata": …} does not reach the server: langchain-openai
1.6 moves metadata into LangChain's own run metadata and never sends it.
Pass it in extra_body.
Client integration notes
Model name
The server ignores model and echoes it back. The model that answers is set
by PlanCover. Use plancover-assistant. Never use a gpt-* or o<N>* name:
@ai-sdk/openai changes how it converts messages for those names, and
LangChain moves some of them to the Responses API.
Setting metadata
metadata carries the conversation id, and the session for clients that cannot
set headers. See the request reference.
| Client | Where metadata goes |
|---|---|
| OpenAI SDKs | the metadata parameter |
@ai-sdk/openai-compatible | providerOptions.<name>.metadata, where <name> is the provider's name |
@ai-sdk/openai (openai.chat()) | providerOptions.openai.metadata |
| AgentKit | defaultParameters: { metadata } |
| LangChain | extra_body={"metadata": …} |
Send the session as the X-PlanCover-Session header wherever the client can
set headers. metadata.session_id is for clients that cannot; the header wins
when both are sent.
Confirmations and sign-in
To let members approve actions and sign in from your client, declare the
confirm_action and sign_in tools, without running them, name the
conversation yourself, and send the member's session. See
Confirmations and sign-in.
Retries and timeouts
OpenAI-style SDKs retry 429 and 5xx twice by default, honouring
Retry-After. For a streaming call that is safe: every refusal comes
before the turn starts, and the stream sends a byte at once and a
ping every 15 seconds after that.
A non-streaming call sends nothing until the turn ends, which can take up to
TURN_TIMEOUT_SECONDS (120 seconds by default) plus about 10 seconds to wind
down. If your client gives up first
and retries, the turn runs a second time on the same conversation. So for
non-streaming calls:
- set the client timeout to at least 150 seconds;
- turn automatic retries off (
maxRetries: 0,max_retries=0), and retry a429yourself afterRetry-After.
client = OpenAI(base_url=..., api_key=..., max_retries=0, timeout=150)A non-streaming turn that fails after it started answers with
x-should-retry: false, which the OpenAI SDKs obey.
Keys and system prompts
- Keep the key in a server environment variable, never in a browser or app bundle.
systemanddevelopermessages are dropped unread. The assistant has its own instructions, and a client's cannot change them.