Skip to content

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

POST /v1/brains/{brain_id}/chat/completions
  • Brain-scoped: the brain comes from the path. Clients point their base_url at http://<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 model field is echoed, not used for routing (pick any value).
  • Only the answer verb is served (grounded, cite-then-verified answers from the published brain).
  • stream: true returns SSE chat.completion.chunk events ending in data: [DONE]. stream: false returns a single chat.completion object.
  • 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:

GET /v1/me      Authorization: Bearer <sk-polaris-… or JWT>
{
  "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.

curl -s https://<host>/v1/me -H "Authorization: Bearer $POLARIS_API_KEY"

Older tenants may predate /v1/me (it returns 404). 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.