Authentication
There are two credentials and they are not interchangeable.
| Credential | Lives | Used for |
|---|---|---|
Project API key (pk_…) | your server, only | minting end-user tokens; project administration |
| End-user token (JWT) | your frontend, or your server | every data-plane call: chat, documents, actions |
The rule: your server mints, your client calls. A token carries the permissions, so your frontend never holds anything that could widen them.
A project key carries a scope, chosen when you create it:
| Scope | What it can do |
|---|---|
mint (default) | Mint end-user tokens for this project, and read its capability ceiling. Nothing else. |
admin | Every Project API call: the ceiling, the corpus, skills, further keys, deleting the project. |
Almost every backend wants mint. It is the difference between a leaked key costing
you some tokens and costing you the workspace — an admin key can raise the project's
own ceiling and delete everything in it.
An admin key satisfies a mint requirement, never the reverse. A mint key used on an
admin route is refused with a 403 that says so. Either way: keep it in a secret store,
one per environment, and revoke it in the dashboard the moment it might have leaked.
Minting a token
curl -X POST "https://oberik.com/api/projects/$PROJECT_ID/token" \
-H "X-API-Key: $OBERIK_PROJECT_KEY" \
-H "content-type: application/json" \
-d '{
"subject": "acme:finance:user_9f3c",
"scope": "acme:finance:user_9f3c",
"capabilities": ["chat", "documents:read"],
"expiresIn": 3600
}'
{ "access_token": "eyJ…", "expires_in": 3600, "capabilities": ["chat", "documents:read"] }
Send it as Authorization: Bearer <access_token>.
Fields
| Field | Meaning |
|---|---|
subject | Who this token is. Owns whatever it creates, and is the default visibility boundary. |
scope | What it may see — a path prefix. Omit to mean "only this subject's own data". |
capabilities | What it may do. Intersected with the project's ceiling; you can narrow, never widen. Modalities are the exception — see below. |
roles / groups | Labels used by document ACLs — except three role names that are privilege grants. See below. |
models | Restrict this token to a subset of models. |
maxEffort | Cap reasoning effort. |
maxToolIterations | Cap agent↔tool loops for this token. Unset means unlimited. |
expiresIn | Seconds. Keep it short and mint per session. |
admin, owner and service are not labels
Three role names mean something to the data plane. A token carrying admin, owner or
service:
- reads every end-user's chat sessions in the project, not just its subject's —
chat.sessions.list()returns the whole tenant; - reads every document and memory regardless of owner (
self-visibility is the one exception, which even an admin cannot see); - is what
ai.audit.listandai.audit.forgetrequire.
That is the entire per-end-user isolation boundary, switched off by a string. If your
backend maps its own users' roles onto this field — and admin is the most likely name
to pass straight through — one customer's admin gets every customer's conversations.
Use groups, or any other name, for ACL labels; those carry no privilege. Mint an
admin token deliberately, for a back-office caller, and never from a role name that
arrived in a request. The mint response returns a warnings array whenever a
privileged role was granted, so you can assert on it in a test.
Read warnings on every mint
The same array says when a capability you asked for is not on the token, which is otherwise invisible until something else refuses:
{ "capabilities": ["chat", "input:file", "output:file"],
"warnings": [
"'tasks' is not a capability — did you mean 'tasks:read' or 'tasks:write'? Nothing was
granted for it, and a request needing it will be refused later by whichever call needs it.",
"'computer' was not granted: this project's ceiling does not allow it. Turn it on under
Capabilities in the dashboard, or mint without it — the agent will otherwise simply say
it cannot do the thing, which is not something you can assert on."
] }
Two different mistakes with two different fixes: the first is a spelling, the second is a
dashboard toggle. Without them a mistyped name mints happily and the 403 arrives from
tasks.create forty seconds later, and a ceiling-trimmed capability shows up only as the
agent explaining in prose that it cannot help — which no test can assert on.
Capabilities
A capability is a permission the data plane checks on every call. The project's Capabilities page is the ceiling; a token can hold any subset of it.
List the ones this end-user needs. The modality capabilities —
input:<kind> and output:<kind> — are the one thing you don’t have to restate: leave
them out and the token carries whatever the project is configured for. They describe
what the agent you built can perceive and hand back, which is a property of the project
rather than a decision about one user, and reading their absence as a refusal meant an
app that listed its tool permissions ended up with an agent that could write a document
and not give it to anyone. Name any output:<kind> and you're being explicit: that side
narrows to exactly what you listed. The two sides are independent, and neither can
exceed the ceiling.
The full set, grouped the way the dashboard groups them. Every one is also declinable
per request with the enable_* flag beside it.
| Capability | Grants | Per-request flag |
|---|---|---|
| Conversation | ||
chat | conversations | — |
system_prompt | send a per-request system message (the project's still applies, and wins) | — |
steer | a message into a running turn | — |
voice | talk to the agent — a live full-duplex call, not a transcript round trip. The per-request flag governs the agent's own conversation controls (hold, resume), not whether a call can be opened | enable_voice_control |
| Knowledge | ||
documents:read / documents:write | search / upload and manage documents. Setting the project to read-only documents stops documents:write ever being minted | enable_rag |
memory | durable memory and the wiki | enable_memory |
| Internet | ||
web_search | public web search and page reads | enable_web_search |
browser | drive a real browser — click, type, scroll, screenshot | enable_browser |
browser_handoff | hand a page to the user when only a person can act | — |
| Agentic | ||
todo | the plan it keeps across a conversation | enable_todo |
computer | sandboxed compute | enable_computer |
subagents | work handed to another agent beside it | enable_subagents |
tasks:read / tasks:write | scheduling | enable_scheduling |
triggers | webhook triggers an end-user may create | — |
| Human in the loop | ||
ask_user | pause on a question and wait for an answer | enable_ask_user |
approvals | stop before something irreversible | enable_approvals |
ui_tools | components it draws into your UI | — |
voice:transfer | on a call, hand the caller to a person — they wait on hold while whoever picks up is briefed, and come back to the agent if nobody does | — |
followups | suggested next messages, generated beside the conversation | — |
recap | a one-line "where we got to" for someone returning | — |
auto_title | name each conversation from its first message; titles stay empty otherwise, and a title you set yourself needs no capability | enable_auto_title |
| Extensions | ||
webhook_tools | the agent may call the tools you publish as URLs — they work in unattended runs, where a client-side tool has no client | enable_webhook_tools |
mcp:manage | end-users may attach their own MCP servers | — |
plugins:write | end-users may upload their own skills. Using the ones you published needs no capability — decline them for a turn with enable_plugins: false | enable_plugins |
action_space | relevance-based tool pre-selection for large catalogs | enable_action_space |
| In and out | ||
input:file | a user may attach any file; what the model can't perceive is read as text (OCR, transcript, extraction) | — |
input:image / audio / video | the model may perceive that kind directly, rather than through extraction | — |
output:file | the agent may hand back any file it produced — a document, a sandbox export, a screenshot | — |
output:image / audio / video | the model may generate that kind | output_modalities |
Anything not granted is refused — including a tool the model tries to call anyway. That's the point: prompt injection can't reach a capability you didn't grant.
// A read-only research token.
capabilities: ["chat", "documents:read"]
// Narrow further per request: this turn may only read documents, nothing else.
await ai.chat.send({ message, allowed_tools: ["rag_search"] });
A token like the one above carries the project's modality capabilities too —
output:file and output:image are inherited rather than listed (Files &
media) — so it can hand back a file the agent produced.
It cannot reach the network. Every tool that fetches something is behind a capability this
token does not list: web_search and browse_url behind web_search, the browser family
behind browser, and screenshot_url behind web_search as well (it fetches a URL to
make the image it returns). That last one used to be gated on the output modality alone,
which meant this exact token could read an external page — details.
allowed_tools still narrows further per request when you want a capability granted and
one tool withheld.
Scopes: isolating users inside one project
scope is a path prefix, with : as the separator. A token sees its own subtree.
subject | scope | Sees |
|---|---|---|
acme:fin:ana | acme:fin:ana | only Ana's own documents and sessions |
acme:fin:lead | acme:fin | everything in the finance team |
acme:admin | acme | the whole customer |
Use one project per customer for hard isolation, and scopes for the hierarchy inside that customer.
Rotation and expiry
Mint a token per user session with a short expiresIn. Rather than handing the SDK a
static token and dealing with expiry at every call site, give it getToken — the SDK
calls it back with { expired: true } when the current token is finished with, replays
the request with the new one, and your code never sees the 401:
const ai = createClient({
getToken: async ({ expired }) => {
if (!expired && cached) return cached; // your own cache
cached = await mintFromYourBackend(); // the endpoint above
return cached;
},
});
This is the recommended way to authenticate. Details worth knowing:
- Two things call you back with
{ expired: true }, and the second is the one that surprises people. The first is a401— the server rejected the token. The second is a local check: if the token you returned expires within the next 60 seconds, the SDK asks for another one before sending anything. So you will see{ expired: true }with no request having failed. That is a401avoided rather than recovered from. - The check is on the token's remaining life, not on the
expiresInyou minted it with. A short-lived token trips it straight after minting; a long-lived cached one trips it in its final minute — which is the recommended shape working as intended, not a problem. There is no TTL for which it never fires. - Either way it costs one extra mint per client, not per request. If the replacement is
also inside the margin (a project whose whole TTL is under a minute), the SDK stops
checking ahead for that client rather than minting on every call, and expiry goes back to
being handled by the
401. - Concurrent requests share one refresh, so a page that fires five calls at once doesn't mint five tokens.
- A request is replayed at most once, so a token that's still rejected surfaces as a
normal
401instead of looping. - An interrupted stream re-attaches to the same run with the new token — an answer in flight is neither lost nor paid for twice.
- If your callback throws, the original
401is what you get, not the callback's error.
A static token still works and is fine for a script or a server job shorter than the
TTL — but nothing can rescue it once it expires.
Narrowing a token you already hold
A backend that already has a project credential can mint a narrower one without going back to the control plane:
const { access_token } = await ai.auth.token({
user_ref: user.id,
capabilities: ["chat", "documents:read"],
scope: `${user.orgId}:${user.id}`,
allowed_models: ["openai/gpt-4o-mini"],
max_effort: "low",
expires_in: 900,
});
Every field here is intersected or clamped against what the calling credential holds, so
a restricted token cannot mint a broader one. That covers what the child may do:
capabilities, scope, data_scope, allowed_models, max_effort, the tool-iteration and
context ceilings, and the subject it may be minted for — a bound the caller holds is
applied whether or not you mention the field, because an absent claim reads as
unrestricted.
expires_in is the exception, and it is deliberate. It is clamped against the
project's maximum rather than the caller's remaining lifetime, so a 10-minute token can
mint a 24-hour one. The child holds no more privilege than its parent — it is the same
grant, for longer — and a short-lived credential refreshing itself into a session is a
pattern worth keeping. Size a token's lifetime by what it may do, not by the lifetime of
whatever minted it.
allowed_models narrows within the project's own registrations; it is not what keeps a
token inside them. A request may only name a model this project registered, whatever the
token says:
model: "gpt-4o" // on a project that registered only "hukuk-db"
400 model 'gpt-4o' is not available to this project. Registered: hukuk-db
That boundary is checked before the request goes anywhere, so a name the project never
configured cannot reach a shared deployment, cannot spend on anything but the project's own
key, and cannot escape its cap. Use allowed_models to narrow further — pinning a browser
token to your cheapest model — rather than as the thing standing between projects.
Project API keys are shown once at creation and can be revoked from the dashboard. Revoking one immediately stops new tokens being minted with it; already-minted tokens expire on their own schedule.
It can mint tokens with any capability the project allows. If one leaks, revoke it in the dashboard and create a new one.