Admin portal
Where PlanCover staff manage API keys and the model, read usage, cost and the audit log, and, when enabled, conversations.
The admin portal is PlanCover's own web app for running the chat API. It is for PlanCover staff, not for integrators: if you call the API, ask the PlanCover team for a key and skip this page.
It holds no chat data of its own. Every page reads or changes chat-api through
the admin API, with one admin key that only the portal
holds.
| Page | What it does | Role |
|---|---|---|
| Keys | Create, edit and revoke API keys | admin |
| Model | Choose the models every turn uses | admin |
| Usage & cost | Requests, tokens and cost per key per day | viewer, admin |
| Audit | Every tool call and confirmation | viewer, admin |
| Conversations | Read a conversation's transcript, when enabled | admin |
Accounts and sign-in
There is no sign-up. An operator creates each account from the shell, and the command prints a one-time password once:
node scripts/create-user.ts --email <email> --name <name> --role viewer
node scripts/reset-password.ts --email <email>--role is viewer or admin.
On the first sign-in the portal asks for a new password (at least 12 characters), then for an authenticator app. TOTP is mandatory: until an account has enrolled, it can open none of the pages below. Sign-in is the password, then the authenticator's code or one of the backup codes shown at enrolment. Each backup code works once.
reset-password prints a new one-time password and ends every session of that
account. The authenticator stays enrolled unless you pass --reset-totp, for a
lost phone.
A session ends after 12 hours idle, and after 7 days in any case.
Roles
| Role | Sees |
|---|---|
viewer | Usage & cost and Audit |
admin | Every page |
The sidebar shows only the pages a role may open, and any other page answers "not found". The portal is the only place the two roles differ: chat-api trusts whoever holds the admin key, so it cannot tell a viewer from an admin.
Keys
Lists every key with its display form (pcai_live_…a1B2), role, state, limit,
expiry and when it was last used.
- Create. Give a name, a role (
clientfor an integration), and optionally a per-minute limit and an expiry. The new key is shown once, with a copy button. Copy it before you press "Done": the server keeps only its hash, so a lost key can only be replaced. - Edit a key's limit and expiry. An empty limit means the deployment's
default (
API_KEY_RATE_LIMIT_PER_MINUTE, 120 a minute unless set);0means unlimited. An empty expiry means never. - Revoke by typing the key's name. It takes effect on the next request,
which gets
401 invalid_api_key. A revoked key stays in the list.
Expiry times are in UTC. Authentication covers what a key may call and how its limit works.
Model
Three OpenRouter model ids, free text, such as openai/gpt-4o-mini:
| Field | Used for |
|---|---|
| Gather model | Routes the turn and calls the tools |
| Answer model | Writes the reply the member reads |
| Fallback model | Takes over when a primary model is unavailable |
A change applies from the next turn, with no deploy. An empty field falls back to the environment's value, which the form shows in its placeholder. The page also shows the OpenRouter provider order, whether other providers are allowed, and who changed the settings last.
The answer model configured for a turn is message.started.model on the
native stream. /v1/chat/completions echoes the model
your request sent instead, so the admin-set model never changes what an
integrator sees there.
Usage & cost
One row per key per UTC day: requests, errors, prompt and completion tokens, and cost in US dollars. A key's name links to that key alone.
- A request is one turn, on any chat route:
/v1/chat/completions,/chat/stream,/chat/confirmor/chat/resume. A turn that failed counts as an error; one the client hung up on counts as a request but not an error. - Tokens add up every model call in the turn, the router and fallbacks
included, but not the reranker. They match the
usagethe turn reported to the caller. - Cost is OpenRouter's own figure. It is blank for a day with no reported cost. A turn cut short can cost slightly more than recorded, because a model call still in flight is never counted.
Usage rows are kept for 400 days.
Audit
Every tool call and every confirmation, newest first, filtered by date range, tool name and outcome:
| Column | Holds |
|---|---|
| Kind | tool or confirmation |
| Tool, Risk | The tool and its risk level |
| Outcome | For a tool, ok or the failure code. For a confirmation, its status: pending, approved, rejected, superseded or expired |
| Duration | How long the tool ran |
| Conversation, Member | The conversation id, and the member's telemetry id |
The log is metadata only. Tool arguments, previews and confirmation summaries
can hold member data, so they are never shown. A member is their telemetry id,
the same id as in Langfuse and the logs, never their login. It is blank when
chat-api runs without ENCRYPTION_KEY.
Conversations
A list of conversations whose last message falls in a date range, and each one's transcript.
Off outside dev
The conversation viewer is behind ADMIN_CONVERSATIONS_ENABLED. Unset, it
is on in dev and off in staging and prod. While it is off, the page is
not in the sidebar and chat-api answers 404 to both routes. Turning it on
in prod waits on the data-protection sign-off.
A transcript shows the member's and the assistant's text only, with secrets redacted. Tool calls, tool results and internal messages are left out. Every transcript you open is recorded in chat-api's admin log under your email, before the transcript is read.
Reports
Usage & cost, Audit and Conversations share one form:
- From and To are whole UTC days, both included. The default is the last seven days.
- Results come 100 rows a page. "Next page" goes forward; "First page" starts over.
Every change is logged
Each key created, edited or revoked, each model change and each transcript
read writes a row to chat-api's admin_events table, in the same transaction
as the change, with the staff member's email. A change whose log row fails is
not made. Saving the model form with nothing changed writes nothing.
The admin API
The portal is the admin API's only client. It is documented here so the boundary is clear, not for integrations.
| Route | Does |
|---|---|
GET /admin/keys | Lists keys, never the key itself or its hash |
POST /admin/keys | Creates a key; the 201 response holds key, once |
PATCH /admin/keys/{id} | Changes name, rate_limit_per_minute or expires_at |
POST /admin/keys/{id}/revoke | Revokes a key. Idempotent |
GET /admin/settings, PATCH /admin/settings | Reads or sets the three models; null reverts one to env |
GET /admin/usage | Usage per key per UTC day; optional key_id |
GET /admin/audit | The audit log; optional tool and outcome |
GET /admin/conversations | Conversations by last message; optional key_id |
GET /admin/conversations/{id} | One redacted transcript |
- Every call needs an
adminkey andX-Admin-Actor: <staff email>. Aclientkey gets403 permission_denied, and a missing or malformed actor400 invalid_request. Anadminkey is refused on every other route. fromandtoare required on the reports and need a UTC offset (Zor+05:30); a bare time is a422.tois exclusive.- Lists return
{ "data": [...], "next_cursor": "..." }. Passnext_cursorback as?cursor=until it isnull.limitis 1 to 100, default 50. - No response carries a member's
user_ref, only theirtelemetry_id. - Errors use the native envelope.