API Keys
BasePeak.AI uses scoped bearer tokens for programmatic access. A token is tied to the user who created it, carries an explicit list of scopes, and can never do more than its creator could do at the moment it was minted.
Creating a key
The UI lives at Profile → API Keys.
- Click Create API key.
- Fill in:
- Name — a short label, shown in the list. Required.
- Description — optional free text.
- Expiry — optional. Without one, the key lives until revoked.
- Scopes — at least one. Use Add scope to choose the type of access and — where relevant — the agent, the credential, or "Any agent"/"Any credential".
- Copy the token. It is shown exactly once — only a hash is stored, so a lost token cannot be recovered, only replaced.
The dialog offers the scopes your account is expected to be able to issue:
chat and credential:use: scopes always, the file and conversation scopes
for any agent you are authorized for (agent:files-write additionally only
if you hold the agent:update permission yourself), and a management
scope only if you hold the matching permission
yourself. Management scopes always apply to every object of their type —
the one exception is agent:update, which can be pinned to a single agent.
The server always has the final say when you create the key, and reports
the reason if it refuses.
For the chat and the file/conversation scopes, the agent picker offers the
agents the key can be minted for — including those you reach only through a
group grant or a project of your own. System agents and grants pointing at a
deleted agent are left out: both would mint, but neither is a sensible target
for a key. agent:update follows a different
rule: that permission is not tied to a specific agent, so its agent picker
lists every agent on the instance.
Programmatic API access is a premium feature. Key creation fails with 403 —
API access is disabled for this instance — when the apiAccess feature is
off, and existing keys stop authenticating too. The flag follows your
subscription and is not self-service; contact your BasePeak.AI representative
if you hit this.
Token format
sk-bpai-<user_id>-<key_id>-<secret>
Only the last segment is secret; the rest identifies which key is being
presented. The stored masked form (sk-bpai-1-42-…jvT8) is safe to put in
logs and is what the UI shows you after creation.
Using a key
Send it as a bearer token:
curl -sS https://your-instance.platform.basepeak.ai/api/invoke/a17jlm7 \
-H "Authorization: Bearer sk-bpai-1-42-…" \
-H "Content-Type: text/plain" \
--data 'Hello!'
Headers by surface
| Header | Where | Purpose |
|---|---|---|
Authorization: Bearer <token> | everywhere | The token. |
X-BPAI-Agent: <agent> | /v1/chat/completions only | Which agent to run. Must be covered by the key's scope. |
X-BPAI-Thread-Id: <thread> | both chat surfaces | Continue an existing conversation. |
Accept | /api/invoke/{id} | Selects streaming vs. JSON vs. plain text. |
On /api/invoke/{id} the agent comes from the URL path, not a header.
See Invoke API and
OpenAI-compatible API.
With the OpenAI SDKs
from openai import OpenAI
client = OpenAI(
api_key="sk-bpai-1-42-…",
base_url="https://your-instance.platform.basepeak.ai/v1",
default_headers={"X-BPAI-Agent": "a17jlm7"},
)
resp = client.chat.completions.create(
model="ignored", # the agent's own model is used
messages=[{"role": "user", "content": "Hello!"}],
)
print(resp.choices[0].message.content)
import OpenAI from "openai";
const client = new OpenAI({
apiKey: "sk-bpai-1-42-…",
baseURL: "https://your-instance.platform.basepeak.ai/v1",
defaultHeaders: { "X-BPAI-Agent": "a17jlm7" },
});
const resp = await client.chat.completions.create({
model: "ignored",
messages: [{ role: "user", content: "Hello!" }],
});
console.log(resp.choices[0].message.content);
Scopes
A scope has three segments:
resource:action:id
resource and action come from a fixed list — neither may be a wildcard.
Only id may be *.
Chat scopes
| Scope | Grants |
|---|---|
agent:chat:a17jlm7 | Chat with that one agent |
agent:chat:* | Chat with any agent its owner can access |
credential:use:<credential> | Lets the key use a stored credential — and the tools that need it. See below. |
A wildcard chat key is re-checked against its owner's own permissions on every call, so it can never reach an agent its owner could not open in the browser.
Credential scopes decide which tools the agent keeps
This is the most common cause of "the agent works in the browser but ignores its tools over the API".
An agent's tools often need a stored credential — a Google account for
Calendar, a token for GitHub. On every API-key call the run is filtered
against the key's credential:use:… scopes:
- credentials the key does not cover are removed from the agent, and
- every tool that needs one of those credentials is dropped from the run.
This happens silently. There is no error and no warning in the response: the agent simply answers as if the tool did not exist.
| Key scopes | Effect |
|---|---|
agent:chat:<id> only | Every credential-backed tool is stripped. The agent can only answer from its instructions and knowledge. |
agent:chat:<id> + credential:use:* | Nothing is stripped. The agent runs with all its tools. |
agent:chat:<id> + credential:use:google | Only tools whose credentials are covered survive; the rest are dropped. |
So if your integration needs the agent's tools, mint the key with
credential:use:* (or list the specific credentials):
{ "name": "calendar-bot", "scopes": ["agent:chat:a17jlm7", "credential:use:*"] }
Tools that need no credential are never affected.
Management scopes
Nine further permissions unlock the management API:
| Scope | Grants |
|---|---|
agent:create:* | Create, list, read, copy, export, import agents |
agent:update:* | Modify agents and their knowledge-set attachments |
agent:delete:* | Delete agents |
knowledgeset:manage:* | Knowledge sets, their files, imports and exports |
tool-reference:manage:* | Tools and MCP servers |
model-provider:manage:* | Models and model providers |
trigger:manage:* | Webhooks, email receivers, cronjobs |
task:manage:* | Tasks and task runs |
team:manage:* | Agent teams |
Management scopes must use * as the id — except agent:update, see
below.
Several scopes per key
A key may carry as many scopes as you need. Mixing chat and management scopes on one key is supported and normal:
{
"name": "provisioning-bot",
"scopes": ["agent:chat:*", "agent:create:*", "knowledgeset:manage:*"]
}
At creation time the server checks that you currently hold the agent and
management permissions you are asking for. Requesting one you do not have is
rejected with 403, which prevents minting a key that would be either dead or
an escalation. (credential:use: scopes are deliberately not checked this
way.)
Scopes that name no reachable route are, as a rule, rejected outright rather
than stored as inert entries — mcp:*, agent:read and admin:* are not
valid scopes.
File and conversation scopes
Four further scopes cover files and conversation history. Each is keyed on an
agent, so thread:files:a17jlm7 means "the threads of that agent":
| Scope | Grants |
|---|---|
agent:files:<agent|*> | List and download files in the agent's own workspace (read-only). These files seed every new conversation. |
agent:files-write:<agent|*> | Everything agent:files grants, plus upload and delete. A superset of agent:files — a key does not need both. Can only be minted by an account that itself holds the agent:update permission (see below); a plain chat user cannot self-grant write access to an agent's standing workspace. |
thread:files:<agent|*> | List, download, upload and delete in a single conversation's workspace (both directions, one scope). |
thread:read:<agent|*> | List that agent's conversations (GET /api/threads), fetch one (GET /api/threads/{id}) and replay its history (GET /api/threads/{id}/events) — the key account's own conversations only. See Threads. |
They are granted like chat scopes: you must be able to reach the agent to mint
them. agent:files-write additionally requires you to hold agent:update
yourself — minting fails with 403 otherwise, the same way a management scope
does when you lack its permission. Beyond that, these scopes are independent
of each other and of agent:chat — a chat key does not get file access, and a
file key cannot chat.
POST /api/threads/{id}/abort needs no scope of its own: starting a run and
cancelling it are the same authority, and agent:chat covers both.
Like management scopes, these can be granted in the Profile → API Keys →
Create API key dialog, or created through REST (POST /api/account/apikeys).
Project scopes
Eight further scopes address a project rather than an agent — the unit
the browser itself uses for a conversation. Each takes a concrete p1…
project id or *:
| Scope | Grants |
|---|---|
project:chat:<p|*> | Start and continue conversations in the project |
project:files:<p|*> | List and download the project's shared workspace |
project:files-write:<p|*> | Also upload and delete; a strict superset of project:files |
project:knowledge:<p|*> | List the project's knowledge set (including ingestion state) and download |
project:knowledge-write:<p|*> | Also upload and delete; a strict superset of project:knowledge |
project:tasks:<p|*> | The project's tasks: create, read, update, delete, run, and their runs |
project:threads:<p|*> | Read the project's conversations: list, fetch and replay them |
project:env:<p|*> | Read the project's environment variables — values included |
project:env-write:<p|*> | Set the project's environment variables |
project:files-write and project:knowledge-write are strict supersets
of their read siblings — a key holding the write half never needs to also
mint the read half.
project:env and project:env-write are independent halvesUnlike files and knowledge, the write half here does not imply the read
half, or the reverse. project:env reads back the environment variables'
stored values — this is where a project's tool secrets live.
project:env-write sets them. A key that only needs to rotate a secret
should never have to be able to read one — that is why this one scope
breaks with the otherwise-general pattern. Details and examples:
Projects.
project:tasks is deliberately not split — one scope covers read,
write and run alike in the project, mirroring task:manage on the flat
surface.
project:threads is not split either, for the opposite reason: it is
read-only and has no write half. Editing or deleting a conversation is
not on the API-key surface at all, and cancelling a running turn
(POST /api/threads/{id}/abort) stays on the chat scope, since starting a
run and stopping it are the same authority.
project:chat does not include reading the conversation backA key holding only project:chat:<p> can start and continue conversations,
but cannot list, fetch or replay them — it has to capture the
X-Bpai-Thread-Id response header and remember the id itself. Grant
project:threads:<p> alongside it so the integration can read its own
conversations back.
Reach for project:threads:<p> rather than thread:read:<agent> whenever
the intent is "this integration reads its own project": thread:read is
keyed to the agent, so it grants every conversation of that agent, across
every project it backs. See Conversations.
All nine project scopes ground on project membership alone. Unlike
agent:files-write, minting them — including the write halves — requires no
management permission: the session path demands nothing more than project
access for the same mutations. Full routes, examples and error cases:
Projects.
task:manage vs. project:tasks
The two task scopes overlap without either containing the other:
| Scope | Reach |
|---|---|
task:manage (*-only) | Every task its owner owns, across all projects — no way to pin it to a single project. |
project:tasks:<p> | Every task in that one project — including one the owner reaches only through a ThreadAuthorization grant, not through ownership. The flat surface (GET /api/tasks) cannot see this case at all, since it filters on ownership alone. |
Task creation also exists only on the project-scoped route — there is no
flat POST /api/tasks.
Grounding a key to a single agent
agent:update is the one management permission that accepts a concrete id:
| Scope | Reach |
|---|---|
agent:update:* | Any agent its owner can update |
agent:update:a17jlm7 | Only agent a17jlm7 — 403 on any other |
Combine it with a matching chat scope to get a key that can talk to one agent and modify that same agent, and nothing else:
{ "name": "self-improve", "scopes": ["agent:chat:a17jlm7", "agent:update:a17jlm7"] }
Grounding narrows only the key. It does not change what you, as its creator, must already hold.
Managing your keys
The list shows each key's name, masked token, creation time, last use and expiry. Last-use is updated asynchronously and at most once a minute per key, so a very recent call may not be reflected immediately.
Revoking takes effect at once — the next request with that token gets
401.
Rotating issues a new secret for the same key: id, name, scopes, expiry and the audit log all stay; the old secret stops working immediately, and the new one is shown exactly once.
Instance administrators additionally see every user's keys under Access → API Keys in the admin area — masked, with owner and audit log — and can revoke any of them. Secrets are never stored or shown; even an administrator can inspect, but never impersonate a user.
REST reference
These routes are for the browser session that owns the keys. API keys cannot mint or manage API keys.
| Method | Path | Description |
|---|---|---|
GET | /api/account/apikeys | List your keys. Never includes the secret. |
POST | /api/account/apikeys | Create a key. The only response that contains the plaintext token. |
GET | /api/account/apikeys/{id} | Metadata for one key. |
GET | /api/account/apikeys/{id}/audit | This key's 50 most recent audit rows. Not paginated. |
DELETE | /api/account/apikeys/{id} | Revoke. Returns 204; a repeat call returns 404. |
POST | /api/account/apikeys/{id}/rotate | New secret for the same key; the response contains it exactly once. |
Create request:
{
"name": "ci-pipeline",
"description": "Release notes generator",
"scopes": ["agent:chat:a17jlm7"],
"expiresAt": "2026-12-31T23:59:59Z"
}
Create response — note key, which you will not see again:
{
"id": 42,
"userId": "1",
"name": "ci-pipeline",
"description": "Release notes generator",
"key": "sk-bpai-1-42-sJ_jvT…",
"maskedKey": "sk-bpai-1-42-...jvT8",
"scopes": ["agent:chat:a17jlm7"],
"createdAt": "2026-06-05T12:34:56Z",
"expiresAt": "2026-12-31T23:59:59Z"
}
At least one scope is required.
Audit trail
Every successful call made with a key is recorded: which key and agent,
the route, the resulting status code and how long it took. Retention
defaults to 90 days. Review the 50 most recent entries via
GET /api/account/apikeys/{id}/audit.
Security model
| Item | Handling |
|---|---|
| The token itself | Never stored. Shown once, then only its hash is kept. |
| Secret at rest | bcrypt-hashed. |
The Authorization header | Stripped immediately after authentication, so it never reaches request handlers or their logs. |
| Route reach | A key is confined to an allowlist — chat, invoke, the agent and thread files, the thread routes (list, get, history, abort) and the management routes. Everything else returns 403, even if scopes might suggest otherwise. |
| Owner re-check | Wildcard chat keys are re-authorized against their owner's current access on every call. |
| Revocation | Immediate. |
Two consequences worth designing around: revoking a key is instant and needs no cache flush, and a key's reach shrinks with its owner's — losing access to an agent disables wildcard keys for that agent right away.
Troubleshooting
| Response | Meaning | What to check |
|---|---|---|
401 auth/bad-token-format | The bearer is not a well-formed sk-bpai-… token | Truncation in an environment variable, or a leftover placeholder value. Compare against the masked token in the UI. |
401 auth/invalid-token | Well-formed but not accepted | Most often revoked; otherwise a mistyped secret. Since - is valid in the secret, a bad copy will not always change its length. |
401 auth/key-expired | Past the expiry date | The response carries expiredAt so you can surface it. Mint a replacement. |
403 auth/api-access-disabled | apiAccess is off for the instance | Not self-service — contact your BasePeak.AI representative. |
403 authz/scope-missing | Scopes do not cover this request. On /v1/chat/completions and /api/invoke/{id}, the body names the requiredScope; the file routes (/api/agents/{id}/files…, /api/threads/{id}/files…) and the thread routes (GET /api/threads, GET /api/threads/{id}, GET /api/threads/{id}/events, POST /api/threads/{id}/abort) return a plain 403 with no requiredScope field. | For /v1, check X-BPAI-Agent matches an agent:chat:<id> scope; for /api/invoke/{id}, the agent in the path. For the thread routes, check the key carries either thread:read for the conversation's agent or project:threads for its project — or agent:chat when aborting. |
403 on a route you expected | The route is not on the API-key allowlist | Listing threads, reading one, replaying its history and aborting an in-flight turn all work with a key — renaming (PUT) and deleting (DELETE) a thread do not. See API Overview. |
404 model_not_found | The agent or alias does not resolve | A misspelled ID, or an alias that does not exist on this instance. |
Threads from a key appear in an "API" project
Expected. Only /v1/chat/completions derives a thread name; threads created
through /api/invoke/{id} stay unnamed. The admin thread list shows the
literal "API" as the project because the key carries an agent scope rather
than a project scope — these threads are intentionally not attached to a
project. See Threads.
That holds for /api/invoke/{id} and /v1/chat/completions. A thread
started through POST /api/projects/{project_id}/invoke is the exception:
it belongs to the project and appears there like any other conversation.
See Projects.
Related docs
- API Overview — which surface to use
- OpenAI-compatible API —
/v1/chat/completions - Invoke API — the native event stream
- Management API — what management scopes unlock
- Projects — the nine project scopes in practice