OpenAI-compatible Advisor endpoint¶
Polaris exposes the Advisor (the read/serve agent) over an OpenAI Chat Completions–compatible
surface, so any OpenAI client (SDKs, TUIs, curl) can query a published brain with no Polaris-specific
code.
Endpoint¶
- Brain-scoped: the brain comes from the path. Clients point their
base_urlathttp://<host>:8088/v1/brains/<brain_id>; the SDK appends/chat/completions. - Deployed hosts drop the port. Where the gateway sits behind a reverse proxy the base URL is
just
https://<host>/v1/brains/<brain_id>— the reference deployment publishes/v1/through nginx behind a Tailscale Funnel. See Edge & Tailscale. - The OpenAI
modelfield is echoed, not used for routing (pick any value). - Only the
answerverb is served (grounded, cite-then-verified answers from the published brain). stream: truereturns SSEchat.completion.chunkevents ending indata: [DONE].stream: falsereturns a singlechat.completionobject.- Every response also carries the Advisor's structured metadata (citations, confidence, guardrail, brain_version) — see Structured metadata.
Auth — long-lived API keys¶
Send Authorization: Bearer <key>. The key is a long-lived API key (sk-polaris-…) or a normal
Polaris access JWT. API keys don't expire (good for standing integrations); JWTs expire (~1h).
Keys are advisor-only. Each key carries a scopes list (v1: ["advisor:read"]). A key is
accepted on the Advisor endpoint (/chat/completions) but rejected on every other endpoint with
403 insufficient_scope — so a leaked advisor key can't manage the brain. The scope model is
extensible (MCP reuses it unchanged in Phase 2).
Error codes¶
Auth failures return the standard envelope {"error":{"code":…,"message":…,"detail":…}} with a
distinct code so clients can react precisely:
| Code | HTTP | Meaning |
|---|---|---|
unauthorized |
401 | No/Bearer-less Authorization header. |
invalid_api_key |
401 | sk-polaris-… token doesn't match any key. |
revoked_api_key |
401 | Key exists but was revoked. |
insufficient_scope |
403 | Key lacks the required scope (e.g. presented to a non-advisor endpoint). |
brain_forbidden |
403 | Key/JWT is bound to a different brain than the path. |
Which brain is my key bound to? — GET /v1/me¶
A key is bound to one brain_id. Hit the wrong brain in the path and you get 403
brain_forbidden — which, on its own, doesn't tell you the right brain. Rather than guess, introspect
your own credential:
{
"auth": "api_key", // "api_key" | "jwt"
"actor": "reader@acme.example",
"brain_id": "calliope", // ← the brain this credential can reach
"role": "Reader",
"account_type": "tenant_user",
"scopes": ["advisor:read"], // a JWT reports ["*"] (unrestricted)
"advisor_base_url": "/v1/brains/calliope", // ← paste this as your OpenAI base_url
"version": "0.5.1", // platform (application) version — the deployed Polaris build
"brain": { // bound brain identity + published knowledge version
"brain_id": "calliope",
"display_name": "Calliope Finance — Lending Compliance",
"status": "active",
"tenant_type": "customer",
"published_version": "147676ad69c2a14afbde0113b4e92de336d55d88", // published corpus SHA (null if unpublished)
"published_version_num": 34,
"published_at": "2026-07-25T03:23:43Z"
},
"api_key": { "id": 14, "label": "tickle-tui", "last_used_at": "2026-07-27T21:49:55Z" }
}
version is the deployed platform build; brain.published_version is the brain's content
version (the published corpus SHA) — the two are independent. The brain block is omitted for an
unbound platform-admin token.
/v1/me is advisor-scoped like /chat/completions (so an advisor key is accepted) and reveals
only the caller's own binding — never other keys or brains. Point your client's base_url at the
returned advisor_base_url and you're done. advisor_base_url is null for a platform-admin token
(not bound to a single brain) — pass the brain explicitly in that case.
Older tenants may predate
/v1/me(it returns404). In that case ask the tenant admin to look up the binding —bb apikey list/GET /v1/brains/{brain_id}/api-keys(Admin JWT) — or to mint you a Reader key for the brain you should be using.
Managing keys¶
From the SPA: Settings → API keys (tenant Admin or platform admin). Create shows the raw
sk-polaris-… once; the list is metadata-only (label, scopes, created, last used) with a revoke
action. Revocation takes effect on the very next request.
Over REST (JWT session; Admin/platform-admin — a key cannot manage keys):
POST /v1/brains/{brain_id}/api-keys {"label": "tickle-tui"} → { …metadata, "key": "sk-polaris-…" }
GET /v1/brains/{brain_id}/api-keys → { "items": [ …metadata ] }
DELETE /v1/brains/{brain_id}/api-keys/{id} → { "id": …, "revoked": true }
From the CLI (every DB-touching command needs APP_CONFIG):
APP_CONFIG=$(pwd)/env/local/app.env uv run python -m app.cli apikey mint \
--brain-id calliope --role Reader --label tickle-tui # → sk-polaris-xxxx (printed once)
APP_CONFIG=$(pwd)/env/local/app.env uv run python -m app.cli apikey list [--brain-id calliope]
APP_CONFIG=$(pwd)/env/local/app.env uv run python -m app.cli apikey revoke <id>
A key is bound to its brain_id; it cannot drive another brain's path (403). Only the sha256 hash is
stored — the raw token is shown once and is unrecoverable.
Scoping a question to a subject¶
A question rarely names the citations it needs, and similarity search cannot bridge the gap because a provision's text does not repeat its own citation. A topic card closes it: a curator names a kind of question and the provisions its answers turn on, and scoping supplies those provisions' verbatim text by exact key.
Pass topic_ids. Card ids come from the MCP topic_cards tool or
GET /v1/brains/{brain_id}/topic-cards — see Advisor MCP.
curl https://<host>/v1/brains/<brain_id>/chat/completions \
-H "Authorization: Bearer sk-polaris-…" -H "Content-Type: application/json" \
-d '{
"messages": [{"role": "user",
"content": "We declined an applicant because their credit score was below our threshold. What notices do we owe?"}],
"topic_ids": ["denying-an-application-the-notices-we-owe"]
}'
From an OpenAI SDK, it rides extra_body:
client.chat.completions.create(
model="polaris-advisor",
messages=[{"role": "user", "content": "…"}],
extra_body={"topic_ids": ["denying-an-application-the-notices-we-owe"]},
)
Both forms are accepted. Top-level topic_ids is what extra_body produces and what most
callers reach for; {"polaris": {"topic_ids": […]}} is the symmetric form, matching the polaris
object on the response. If both are sent the namespaced one wins.
- Optional — omitting it changes nothing. An unscoped request retrieves exactly as before.
- At most 8 ids per request, the same cap the interactive surface enforces.
- An unknown id is ignored, not an error — a stale id degrades to an unscoped answer.
- Every other unrecognised field is still ignored, so the long tail of OpenAI sampling parameters keeps passing through untouched.
Structured metadata¶
The Advisor grounds and cite-then-verifies every answer, so each response carries more than text. That metadata is exposed two ways:
1. The polaris object (always present)¶
Every chat.completion (and the final streaming chat.completion.chunk) includes a top-level
polaris object. Standard OpenAI clients ignore the unknown field; metadata-aware clients and agents
read the grounded citations:
{
"id": "chatcmpl-…",
"object": "chat.completion",
"choices": [{ "index": 0, "message": {"role": "assistant", "content": "…answer…"}, "finish_reason": "stop" }],
"usage": { "prompt_tokens": 812, "completion_tokens": 143, "total_tokens": 955 },
"polaris": {
"citations": [
{
"source_id": "12 CFR Part 1026 — Truth in Lending (Regulation Z)",
"kind": "passage",
"quote": "(1) Act means the Truth in Lending Act (15 U.S.C. 1601 et seq.).",
"locator": "§1026.2",
"score": 0.71,
"verified": true,
"verification": { "method": "entailment", "score": 1.0 },
"provenance": {
"source_doc_pk": 48,
"doc_title": "12 CFR Part 1026 — Truth in Lending (Regulation Z)",
"origin_url": "https://www.ecfr.gov/current/title-12/chapter-X/part-1026",
"jurisdiction": "US-FED"
},
"wiki": [
{ "entity": "Regulation Z", "ref": "/v1/brains/calliope/wiki/Regulation%20Z" }
],
"concept_refs": ["Regulation Z", "Truth in Lending Act"]
}
],
"confidence": 0.82,
"guardrail": { "status": "pass", "rule_id": null, "message": null },
"brain_version": "a1b2c3d…"
}
}
Each citation is a navigable entry point back into the knowledge base:
| Field | Meaning |
|---|---|
source_id, quote, locator |
The cited source and its verbatim supporting span (the entailing sentence, not a prefix). |
kind |
passage (verbatim source chunk — strongest) or entity (graph/entity summary — contextual). |
score |
Retrieval relevance of the source (0–1). |
verified / verification |
verification.method is entailment (LLM judge) / lexical / lexical_fallback (judge errored → unconfirmed) / unverified, with a score. |
provenance |
Hard provenance — source_doc_pk + origin_url (the source document's root URL) + jurisdiction, to deep-link the evidence. |
wiki |
Deep links into the published Wiki (Reader) for each cited concept: GET /v1/brains/{brain_id}/wiki/{entity}. |
concept_refs |
Entity names for graph traversal from the answer into the knowledge base. |
In streaming, the polaris object rides the final finish_reason: "stop" chunk.
2. response_format: {"type": "json_object"}¶
Send this and the message content itself becomes the full envelope as a JSON string — handy when
your client wants one structured payload instead of text + a side channel:
// choices[0].message.content (stringified):
{ "answer": "…", "citations": [ … ], "confidence": 0.82, "guardrail": { … }, "brain_version": "a1b2c3d…" }
response_format: {"type": "json_schema", …} is not supported (HTTP 400): the Advisor's output
schema is fixed (grounded envelope) and cannot be coerced to an arbitrary caller-defined schema —
forcing one would bypass grounding and verification.
Usage¶
usage reports real token counts, summed from the metered LLM calls behind the answer. For
streaming, send stream_options: {"include_usage": true} to receive a final usage-only chunk.
(When metering is disabled for the brain, usage is zeros.)
Using it from tickle-tui¶
In tickle-tui's providers.yaml:
polaris-calliope:
name: "Polaris — Calliope"
base_url: "http://localhost:8088/v1/brains/calliope"
is_openai_compatible: true
api_key_env: "POLARIS_API_KEY" # env var holding the minted sk-polaris-… key
models:
- id: "polaris-advisor" # cosmetic
name: "Polaris Advisor"
Then export POLARIS_API_KEY=sk-polaris-… and run the TUI. The TUI always streams, which this
endpoint supports.
Quick check with curl¶
curl -N http://localhost:8088/v1/brains/calliope/chat/completions \
-H "Authorization: Bearer $POLARIS_API_KEY" -H "Content-Type: application/json" \
-d '{"model":"polaris-advisor","stream":true,
"messages":[{"role":"user","content":"Do I need a CA lender license?"}]}'
Verify end-to-end (no dependencies)¶
cookbooks/verify_completions.py (in the repo) is a standard-library-only
script (runs as-is on Windows and Linux, any Python 3.8+) that discovers your brain via /v1/me, runs
a real grounded answer, and prints the citations — mapping every auth error to a one-line "what to do
next":
python cookbooks/verify_completions.py \
--base-url https://<tenant-host> \
--key-file ~/.secrets/my-key
# or: --key sk-polaris-… | or set POLARIS_API_KEY
# pass --brain-id <brain> to skip discovery; --json for the structured envelope.
Limits (current)¶
Single answer verb only; brain-scoped path only (no model→brain mapping); no /v1/models,
embeddings, or tools/function-calling; response_format supports json_object but not json_schema
(fixed output schema). Streaming is pseudo-streaming — the full grounded answer is computed
(cite-then-verify needs the whole answer), then emitted as deltas.